Back to Blog
'NativePHP Mobile 4.5: Async Tasks Without Freezing the Screen'
AsyncTask runs background PHP work on a separate thread and delivers results back to the UI thread, so long operations don't freeze your Laravel mobile screens. Here's when to use it instead of queues.
Back
Engineering
Table of contents
NativePHP Mobile 4.5.0 shipped on September 18, 2026, with a new AsyncTask feature that fixes a common mobile app problem: screens freezing while waiting for slow work to finish.
If you run a slow report, resize an image, or hit a slow API inline in a handler, the UI thread is busy doing that work instead of re-rendering the screen. The user sees a frozen interface until the work completes.
AsyncTask moves that work onto a separate PHP thread and hands the result back on the UI thread where your component can update state and let the screen re-render.
This is not a queue. It is a different tool for a different job.
Quick Answer
Use AsyncTask when you need short background work (a few seconds) to finish during the current user session without freezing the screen, and the result needs to update the screen immediately after completion.
Use Queues when the work is durable (needs to survive app restarts), retryable, or too long to expect the user to wait for.
AsyncTask starts immediately, runs concurrently, and finishes with a callback bound to your component. Queues are persisted, retryable, and survive app kills.
How AsyncTask Works
You dispatch a static closure. It runs on a background PHP context (a separate PHP interpreter on its own thread with its own memory). Your handler returns immediately. The screen re-renders with a spinner or loading state. When the task finishes, the finished() callback fires on the UI thread, still bound to your live component, so you can mutate state and trigger another re-render.
Here is the basic pattern from the docs:
php
use Native\Mobile\AsyncTask;
public function generateReport(): void
{
$this->generating = true;
AsyncTask::dispatch(static function () {
return ExpensiveReport::build()->toArray();
})->finished(function (array $report) {
$this->report = $report;
$this->generating = false;
});
}dispatch() returns straight away. The screen shows the spinner because generating is true. The task runs. When it completes, finished() fires and updates $this->report, turning off the spinner. The screen re-renders with the new data.
The Static Closure Gotcha
The work closure must be declared static.
If you forget the static keyword, AsyncTask throws InvalidArgumentException at dispatch time, in your handler, where you can see it.
This is deliberate. The work runs in a different PHP thread with its own memory. It cannot see $this, your component's properties, or anything else from the dispatching thread.
Pass data in by capturing serializable values with use:
php
$month = $this->month;
AsyncTask::dispatch(static fn () => Report::build($month));Everything the closure captures must be serializable. Resources, PDO handles, and open file pointers cannot cross the thread boundary and will throw.
Results Round-Trip as JSON
Results travel back as JSON. Return scalars and arrays. For large payloads (an image, a PDF, a big export), write the result to disk in the task and return the path or a cache key:
php
AsyncTask::dispatch(static function () {
$path = storage_path('app/report-' . now()->timestamp . '.pdf');
Report::build()->save($path);
return $path;
})->finished(fn (string $path) => $this->reportPath = $path);Do not try to return the file contents. Send a reference and let the UI thread load it when needed.
Scoped vs Shared Callbacks
By default, finished() and failed() callbacks only fire if the screen that dispatched them is still on top. If the user navigated away before the task completed, the callback is dropped.
This makes sense. The callback is bound to a live component instance so it can mutate state. Firing it against a screen the user left would update something nobody is looking at, or worse, something in a completely different component.
For tasks whose results matter regardless of where the user is (a background upload feeding a status bar, a sync refreshing a badge), use shared():
php
AsyncTask::dispatch(static fn () => Sync::run())
->shared('sync-complete');shared() delivers the result as a named event instead of a scoped callback. Any active screen can pick it up with the #[On] attribute:
php
use Native\Mobile\Attributes\On;
#[On('sync-complete')]
public function syncComplete(mixed $result = null, string $status = 'finished'): void
{
$this->lastSync = $result;
}The payload carries id, a status of finished or failed, and then either result (on success) or exceptionClass, message and trace (on failure). Give every parameter a default because success and failure payloads carry different keys.
Task Classes
For anything you would rather not write inline, extend AsyncTask and put the work in handle():
php
namespace App\Async;
use Native\Mobile\AsyncTask;
class BuildReport extends AsyncTask
{
public function handle(int $month): array
{
return Report::forMonth($month)->toArray();
}
}Dispatch it with its arguments. They are passed to handle() in the background context:
php
use App\Async\BuildReport;
BuildReport::dispatch($this->month)
->finished(fn (array $report) => $this->report = $report);The arguments must be serializable, same as a closure's captures.
AsyncTask vs Queues
AsyncTask is not a substitute for queues.
Queues are durable. Jobs are persisted to the database. They survive app restarts. If a job fails, Laravel's standard retry and failure handling applies. The queue worker runs on its own thread and polls in a loop.
AsyncTask starts immediately, runs concurrently, and does not survive the app being killed. There is no automatic retry. If the work should retry, handle it in failed() or reach for a queued job.
When to use AsyncTask:
- Short work (a few seconds) that finishes during the current session
- The result needs to update the screen immediately
- The work does not need to survive app kill
- You want to avoid freezing the UI while the work runs
When to use Queues:
- Durable background work that survives app restarts
- Work that should retry on failure
- Long-running tasks the user does not need to wait for
- Work that does not need to deliver results to a specific screen
Both tools use separate PHP threads. The difference is durability, retry behavior, and how the result is delivered.
Testing
AsyncTask::fake() runs tasks inline and synchronously, so your finished() and failed() callbacks fire during the test with no threads involved:
php
use Native\Mobile\AsyncTask;
it('loads the report', function () {
AsyncTask::fake();
Native::test(ReportScreen::class)
->tap('generateReport')
->assertSee('Revenue');
});The fake also records every dispatch. Available assertions: assertDispatched(), assertNotDispatched(), assertDispatchedTimes(), and assertShared('alias').
Also in 4.5
Async tasks are the headline, but 4.5 also ships:
glow-*halos andblur-*soft page-bg orbs in the Tailwind parser- Gesture areas pick up
pan-xbinding and a@dragEndcallback routes/mobile.phploads automatically in native contexts- PHP 8.3 support restored alongside 8.4
- Pending OTA zips apply on boot
- Native component lifecycle foundations
Plus nativephp/mobile-ui 0.5.0 lands with a full-screen snap pager for feeds, an always-on sheet pane for permanent bottom sheets, and a background layer that stays beneath every screen.
My Take
AsyncTask fills a real gap.
Before 4.5, running slow work inline froze the screen. The alternative was to dispatch a queued job, but that feels heavy when the work is short and the result needs to update the current screen right after it finishes.
AsyncTask is the right tool for that middle ground: work that is too slow to run inline but too short and session-specific to justify a durable queue.
The static closure requirement is correct. It forces you to think about what crosses the thread boundary. The error happens at dispatch time where you can see it, not silently in a background log.
Scoped callbacks are the right default. Most tasks are kicked off by a user action on a specific screen and the result matters only while the user is still on that screen. shared() is there when you need it, but it should not be the default because firing a callback against a screen the user left is usually wrong.
The comparison to queues is worth understanding. Queues and AsyncTask both use separate PHP threads, but queues are durable and retryable. AsyncTask is immediate and ephemeral. Use queues when the work needs to survive app kill. Use AsyncTask when the work needs to finish this session without freezing the UI.
This is not a framework trying to be everything. It is two tools for two jobs.
Upgrade
If you are already on NativePHP Mobile 4.x:
bash
composer update nativephp/mobile nativephp/mobile-ui
php artisan native:install
php artisan native:runnative:install must run after the composer update and before native:run so the native shell picks up the new packages.
Version 4.5.2 is the latest patch as of September 23, 2026.
Sources
- NativePHP Mobile 4.5.0 release announcement (September 18, 2026)
- Async Tasks documentation
- Queues documentation
- NativePHP Mobile changelog (4.5.0 Sep 18; 4.5.1 Sep 19; 4.5.2 Sep 23)
Comments
No comments yet
Loading comments...