Skip to content

Keep screens responsive while their requests are in flight: background work pumped by the runloop, rendered only when a request settles - #455

Closed
sandermj wants to merge 1 commit into
NativePHP:mainfrom
sandermj:feat/background-work
Closed

sandermj wants to merge 1 commit into
NativePHP:mainfrom
sandermj:feat/background-work

Conversation

@sandermj

@sandermj sandermj commented Sep 13, 2026 •

Copy link
Copy Markdown
Contributor

Resolves #456 — the proposal, with the reasoning and the measurements, is in that issue.

Proposal

What users experience today

A NativeComponent runs on a single-threaded runloop: a method runs to completion before the next event is read. For everything local that is fine — SQLite through Eloquent, the filesystem, the app's own PHP all answer in a few milliseconds, and a screen built on them feels instant. The problem starts the moment a screen needs data that lives on a server: a REST or GraphQL backend, a third-party API, any Http::get() that leaves the phone. That call takes a hundred milliseconds on a good network, seconds on a bad one, and a full timeout when the server is down — and the runloop waits for all of it. Any screen that fetches remote data the obvious way, Http::get() in mount() or in a press handler, therefore stops the app for the whole round trip:

  • taps, the back gesture and tab switches are dead until the response is in;
  • a screen pushed with #[Lazy] shows its placeholder, but the placeholder does not respond either;
  • with a slow or unreachable server the app is frozen for the full timeout — ten seconds of a phone that appears hung, and the user's first instinct is to kill it;
  • a screen that needs several requests (a profile, then the lists that depend on it, then details) blocks for their sum.

This is the single biggest difference a user feels between a native app and a NativePHP screen, and it affects every app whose content comes from an API rather than from the on-device database — which is most apps beyond a demo. An app that only reads its own SQLite never notices; an app whose home screen is a feed from its backend hits it on every launch. It is not a bug in any one component: the runloop has no notion of work that is in progress but not finished, so there is no way for a screen to say "start this request, keep taking input, tell me when the answer lands".

Why it cannot be solved properly from user land

Two pieces are available today and can be combined into a workaround:

  1. Start the request on a Guzzle CurlMultiHandler so it goes on the wire at once, return from mount() with a skeleton, and call curl_multi_exec from render() to see whether it has landed.
  2. Render a native:poll="200ms" element while the request is out, so the runloop wakes up, re-renders, and the render ticks the handle again.

That makes the screen responsive, but the poll timer is now doing two jobs at once and does both badly, and neither can be fixed from outside the runloop:

  • The timer is the transfer pump. A transfer only advances on a tick, so the poll interval is the latency floor of every step: a screen with three chained requests takes at least three intervals on top of the network, whatever the network does.
  • Every tick is a full render and a full publish. The runloop cannot know that nothing changed, so a wake-up that moved a few bytes still re-renders the screen and posts the entire tree to the device — five times a second, for as long as anything is out. With the server unreachable that is a minute of publishing a tree that never changes, on every screen that tried to load.
  • The two goals pull in opposite directions. Slowing the timer down cuts the publishes and makes every load slower; speeding it up does the reverse. There is no interval that is right.

Only the runloop can end this, because only the runloop knows two things: when it is idle, and whether the frame it is about to publish is different from the last one.

Proposal

Give NativeComponent a small background-work API, backed by one shared CurlMultiHandler, and make the runloop itself the pump:

public function mount(): void
{
    $this->background(
        Http::setHandler(BackgroundHttp::handler())->async()->get('https://example.test/api/items'),
        fn ($response) => $this->items = $response?->json() ?? [],
    );
}
  • background(PromiseInterface $promise, ?callable $onSettled = null) registers a promise. A Laravel LazyPromise (what Http::async() returns — it only sends on wait()) is built at once so the transfer starts immediately, and mount() returns right away.
  • While anything is pending, the event wait (nativephp_element_wait_event) is capped at 20 ms instead of blocking indefinitely.
  • On an idle tick the runloop advances the handle and runs the promise callbacks that became due (Utils::queue()->run()) — on the runloop thread, so $onSettled writes straight into component state with no locking.
  • The loop re-renders only when something settled (or a #[Poll] method fired). A tick that just moved bytes costs no render and no publish.
  • A rejected promise settles $onSettled with null; the screen decides what to show. hasBackgroundWork() answers whether anything is still out — for skeleton gates and for tests.

Screens that never call background() see no change at all: the timeout stays -1, the idle branch is never entered, the render gate is never set.

What it solves

  • Screens that load from a remote API stay responsive. Taps, back and tab switches work from the first frame; a skeleton is a real, interactive screen. An unreachable server costs the user nothing but a skeleton that turns into an empty state. Local reads are untouched — they were never the problem and stay synchronous.
  • Loads are as fast as the network. A response is picked up within 20 ms of arriving, and chained requests no longer pay a timer interval per step.
  • The device is left alone while nothing happens. One render per settled request, zero while waiting — instead of five tree publishes a second. Less CPU, less bridge traffic, less battery on every screen that loads data.
  • One line per request instead of a per-app framework. The handler, the pump, the wake-up and the render decision live in the runloop; a screen just hands over a promise and a callback.

Measured

On an app whose every screen is fed by a remote API through its own backend (a tabs root with three chained requests, 20+ detail screens, iOS and Android), with every screen migrated onto this:

  • the poll element and its interval are gone from every view; a chained read lands as soon as its bytes do, not on the next timer edge;
  • a cold load of the root screen renders exactly once per settled read (six renders over 3.6 s) and nothing in between, where the poll workaround rendered every 200 ms for the same 3.6 s;
  • a screen that is waiting publishes nothing until its read settles — with the server unreachable, zero publishes instead of five per second;
  • the device keeps taking taps, back gestures and tab switches while a read is out.

Implementation

New: Native\Mobile\Edge\BackgroundHttp — the shared CurlMultiHandler (select_timeout 0, so a tick is a few milliseconds of curl_multi_exec, never a blocking select) plus tick(), which advances the transfers and runs Guzzle's task queue.

NativeComponent:

  • background(PromiseInterface $promise, ?callable $onSettled = null) — builds a Laravel LazyPromise if that is what it got (so the request is on the wire immediately), counts it as pending, and chains a settle callback that decrements the count, marks the component dirty and calls $onSettled with the value (or null on rejection).
  • hasBackgroundWork() — true while anything is pending.
  • pumpBackgroundWork() — ticks the handle when work is pending; returns whether a promise settled during the tick.
  • nextEventTimeout() adds a 20 ms deadline (BACKGROUND_TICK_MS) while work is pending, next to the existing #[Poll] / native:poll deadlines.
  • runDuePolls() now returns whether a poll fired (it used to return void).
  • Both runloops (run() and runLoop()) share the same idle branch: pump the background work, run the due polls, and set nativeSkipRender when neither changed anything. At the top of the next iteration that flag skips the render + publish block once (the published tree and its callbacks stay valid — nothing was re-rendered, so nothing was reset) and goes straight back to waiting.

Testing. A screen that calls background() with a promise it resolves later: hasBackgroundWork() is true and no poll element is in the tree; after the promise resolves, the next idle tick lands the data and hasBackgroundWork() is false. A rejected promise settles the callback with null and the count drops to zero. Existing #[Poll] and native:poll behaviour is unchanged (their deadlines still drive nextEventTimeout(), and a fired poll still re-renders). Verified on the iOS simulator and an Android emulator with an app whose every screen loads this way: reads land without a poll timer, and a waiting screen publishes nothing until its read settles.

Compatibility. Purely additive. Components that never call background() are unaffected: the pending count is zero, the timeout is unchanged, the idle branch only runs when it already ran today (a poll deadline elapsed), and the render gate is only set from that branch. The public-facing BackgroundHttp::handler() is the handle a request has to be built on (Http::setHandler(BackgroundHttp::handler())->async()->…); a plain Http::async() promise passed to background() still works, it is just pumped by Guzzle's default handler on the same ticks. A promise that never settles keeps the 20 ms wake-up alive (cheap: no render, no publish — a curl_multi_exec call and an empty task queue), so give requests a timeout, as Http does by default.

NativeComponent::background($promise, $onSettled) registers a promise
(a Laravel LazyPromise is built so it is on the wire at once) for the
runloop to pump between events: while any is pending the event wait is
capped at 20 ms, an idle tick advances the shared CurlMultiHandler
(Edge\BackgroundHttp) and runs the callbacks that became due, and the
loop re-renders only when a promise settled or a poll fired — a tick that
merely moved bytes publishes nothing. hasBackgroundWork() reports whether
anything is still out. Components that never call background() are
unaffected.
@coderabbitai

coderabbitai Bot commented Sep 13, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: c305da05-4fb5-41c5-8b8e-67a9f705b5e6

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@simonhamp

Copy link
Copy Markdown
Member

@sandermj you should be able to achieve what you're attempting to do here with Async Tasks (#228, released in 4.5.0)

The benefit of async tasks is that they're system level multi-threading async, tied to the runloop. So the integration is way deeper and more flexible than just for HTTP tasks through cURL.

It also avoids the frame-hopping logic that this PR is having to do to achieve its result

@simonhamp simonhamp closed this Sep 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants