Skip to content

fix: stop the bootstrap waiting on workers that cannot activate - #25

Open
pdcruze wants to merge 1 commit into
DoctorinaAI:masterfrom
NeoriseTech:fix/bootstrap-handoff-stall
Open

pdcruze wants to merge 1 commit into
DoctorinaAI:masterfrom
NeoriseTech:fix/bootstrap-handoff-stall

Conversation

@pdcruze

@pdcruze pdcruze commented Sep 27, 2026

Copy link
Copy Markdown
Contributor

What happened

After every deploy, the first two loads for a returning visitor stalled in the bootstrap before Flutter started:

Load after a deploy Before
1st always ~4 s: Activation timed out, continuing without SW
2nd usually fine, but sometimes 8 s: Waiting-worker handoff timed out, continuing with old, then Activation timed out, continuing without SW

In both cases the page then booted with the previous controller, which is the outcome it could have reached immediately.

Root cause

1st load. register() starts installing the new build while the previous worker still controls the page. waitForActivation then waits for the new worker to become active. A worker can't activate while its predecessor still controls a page unless skipWaiting is called, so the wait always runs the full SW_REGISTRATION_TIMEOUT_MS.

2nd load. The new worker is waiting, and activateWaitingAtBootstrap posts skipWaiting. Usually the handover lands within milliseconds. But activation stops the old worker, and a request the page makes at that moment (the browser's favicon fetch, a logo, the web manifest) is still dispatched to the old worker, which restarts it. Chromium then keeps the new worker waiting for as long as the page stays open: the controllerchange never comes, re-sending skipWaiting doesn't help, and activation happens only when the page unloads.

This was reproduced with the example app, a local static server and repeated deploys, recording worker status changes through the DevTools protocol:

  • A handover that won: favicon.png requested → skipWaiting → old worker stopped → redundant → new worker activating.
  • A handover that lost: skipWaiting → favicon.png requested in the same millisecond → old worker stopped → starting → running → no further change for the new worker until the page is navigated away from.

The fix

  • Controlled pages don't wait for a pending worker. A page that already has a controller boots with it straight away when a newer worker is installing or waiting. The newer worker takes over when the page closes, and sw-update-available still announces it. First visits (no controller) wait for activation as before.
  • The handover gets 500 ms. It's now bounded by a new SW_HANDOFF_TIMEOUT_MS (500 ms), since a handover that works lands in milliseconds. After that, a controlled page boots with its current controller.
  • Same outcome, no waiting. In both cases the page ends up exactly where it did before, with its current controller and the new worker taking over on the next load. Only the waiting is gone. The user-approval flow (sw-update-available → applyUpdate) is unchanged.
  • Supporting files: bootstrap_template.dart is regenerated. README and docs/bootstrap-pipeline.md describe the new behaviour, and there's a CHANGELOG entry under Unreleased.

Verification

  • Vitest: 275 pass. There are new tests for a controlled page with an installing worker (returns without waiting), a lost handover on a controlled page (resolves after the new bound) and an uncontrolled page (still waits for activation). All three fail without the fix.

  • Dart: dart analyze is clean, and 102 tests pass, including integration.

  • Playwright: 17 / 17 in Chromium.

  • npm run verify: the committed Dart assets match the build output.

  • Repeated deploys of the example app (Chromium 152, local server):

    Load after a deploy Before After
    1st 4.3–5.0 s (every cycle) 0.31–0.34 s
    2nd, handover won ~0.83 s ~0.84 s
    2nd, handover lost 8.3 s 0.84 s

Merging alongside #24

This branch is based on master and is independent of #24. Whichever of the two merges second will conflict on CHANGELOG.md (both add an Unreleased section) and on the generated lib/src/assets/bootstrap_template.dart. The template should be regenerated with npm run build:all rather than merged by hand. We have the two merged on our fork: Vitest 278, Dart 108 and Playwright 17 all pass, and npm run verify is clean. We're happy to rebase this branch once #24 is in.

After every deploy, the first two loads of an app that already had a
service worker could stall before Flutter started.

The first load registers the new build, which starts installing while
the previous worker still controls the page. waitForActivation then
waited for the new worker to become active, which cannot happen while
the page is controlled, so the wait always ran its full 4s before the
page booted with the old controller anyway.

The next load finds the new worker waiting and activateWaitingAtBootstrap
posts skipWaiting. Usually the handover lands in milliseconds. But
activation stops the old worker, and a request the page makes at that
moment (the browser's favicon fetch, a logo, the web manifest) is still
dispatched to it, which restarts it; Chromium then keeps the new worker
waiting for as long as the page is open, and re-sending skipWaiting does
not help. The bootstrap waited 4s for a controllerchange that could not
come, then 4s more in waitForActivation, again ending with the old
controller.

- A controlled page with a newer worker installing or waiting now boots
  with its current controller at once. The newer worker takes over when
  the page closes, and sw-update-available still announces it. First
  visits (no controller) wait for activation as before.
- The handover is bounded by SW_HANDOFF_TIMEOUT_MS (500ms).

In both cases the page ends up exactly where it did before; only the
waiting is gone.

Tests: Vitest covers a controlled page with an installing worker
returning without waiting, a lost handover resolving after the new
bound, and an uncontrolled page still waiting for activation; all three
fail without the fix. The full Vitest, Dart and Playwright suites pass.
Measured on the example app with a local server and repeated deploys in
Chromium 152: the first load after a deploy went from 4.3-5.0s every
time to 0.31-0.34s, and a lost handover race from 8.3s to 0.84s.
@mike-doctorina

Copy link
Copy Markdown
Collaborator

Thanks — #24 is merged, so this now conflicts on CHANGELOG.md and lib/src/assets/bootstrap_template.dart, as you expected. Could you rebase onto master and regenerate the template with npm run build:all?

CI on this branch merged with master is green (TypeScript, Dart, E2E). The earlier E2E failures were a runner problem on our side, not this change: ubuntu-latest moved to 26.04, which Playwright doesn't support yet. #26 pins the E2E job to 24.04, and a rebase picks that up.

@mike-doctorina

Copy link
Copy Markdown
Collaborator

An update before you rebase: part of this PR now overlaps with #28.

#28 deals with the first load after a deploy more broadly. The worker now reports its build's engineRevision on getVersion, and the bootstrap asks the controller for it:

  • Same engine: the page boots with its current controller at once. That is your first-load change: a controlled page no longer waits on a worker that cannot activate.
  • Different or unknown engine: the bootstrap waits for the new worker to install and hands control to it before any app file loads. Otherwise the previous build's cached Dart code would run on the new engine.

So once #27 → #28 → #29 land, the registerAndActivate part of this PR is covered. What #28 does not include is your bound on the handover: SW_HANDOFF_TIMEOUT_MS (500 ms) in activateWaitingAtBootstrap, which still uses SW_REGISTRATION_TIMEOUT_MS (4 s). Your trace of the lost handover (favicon.png restarting the old worker) is the reason #28 reloads once when a handover fails on an engine change.

Could you rebase onto master after those three are merged and keep just the handover bound, with its test and docs? If you'd rather not, say so and we'll carry it ourselves with credit to you.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants