Skip to content

fix: never let a blocked service worker job queue stop the app from booting - #22

Merged
mike-doctorina merged 1 commit into
masterfrom
fix/bootstrap-sw-registration-watchdog
Sep 16, 2026
Merged

mike-doctorina merged 1 commit into
masterfrom
fix/bootstrap-sw-registration-watchdog

Conversation

@mike-doctorina

Copy link
Copy Markdown
Collaborator

What happened

QA could not open a feature preview: the page sat at 2% Registering service worker forever, across reloads. The console stopped mid-registration — no Registered successfully, no Registration failed — so navigator.serviceWorker.register() had neither resolved nor rejected, and the bootstrap pipeline awaited it with no deadline.

Root cause

Service worker jobs are serialised per scope. An install that never settles holds the queue, and every later register(), unregister() and getRegistrations() on that origin stays pending behind it until the browser times the install out — minutes later.

Reproduced end to end against a worker whose install hangs:

step result
first register() resolves in 32ms, worker stuck installing
next visit's register() never settles
unregister() never settles
caches.delete() works

What produces such an install here is the precache. fetchWithRetry bounds the response headers and clears its abort timer the moment they arrive; cache.put then reads the body — main.dart.js is routinely ~10 MB — with no deadline at all. A stream that dies mid-transfer hangs the install, and with it the whole origin.

The loading widget's reset button could not rescue this either: it awaited unregister() before reloading, so it hung exactly when it was needed.

The fix

Three deadlines, one per hop:

  1. SW_BOOTSTRAP_TIMEOUT_MS (10s) caps the whole service-worker step of the pipeline; the app boots uncached if it expires. The abandoned registration is left running, so the worker still installs once the browser frees the queue. Not a new state — the pipeline already proceeds without a SW whenever activation times out.
  2. BODY_STALL_TIMEOUT_MS (30s) is an idle watchdog on precached bodies: a stream that stops producing bytes is cancelled. Idle time, not total duration, so a slow-but-alive connection still finishes however long it takes. A dead one fails a single install attempt, retried on the next page load.
  3. RESET_TIMEOUT_MS (3s) bounds the reset action, which now always reloads.

Making the reset reachable also made its blast radius reachable, so it is narrowed to the caching layer it is named for:

  • application storage is no longer cleared — on Flutter web shared_preferences is localStorage, so clearing it signed the user out and dropped local state;
  • only the registration this bootstrap owns is unregistered — unregistering everything also dropped push subscriptions belonging to workers at unrelated scopes, which reloadIfForeignController has always been careful to spare.

Verification

  • Vitest: 273 pass. Every new test was mutation-checked — with the fix reverted, all five fail (the hang ones by timeout).
  • Playwright: 17 pass, including a new e2e that wedges the scope's job queue with a never-settling install and asserts the app still reaches its first frame. With the fix reverted that test fails with flutter-view never attached — the QA symptom, in a real browser.
  • Dart: format, analyzer, 89 unit tests, 13 integration tests.
  • npm run verify: committed Dart assets match the build output.

Local e2e ran against the system Chrome: this machine cannot finish a playwright install (download completes, extraction stalls). CI installs its own browsers and is unaffected.

Release

pubspec.yaml → 0.2.0, CHANGELOG updated. README, docs/service-worker.md and docs/bootstrap-pipeline.md document the new deadlines — note docs/service-worker.md previously claimed a 30s install timeout that did not exist.

🤖 Generated with Claude Code

https://claude.ai/code/session_015Qkiw3K6ozvtWNHCTmFiAK

…ooting

A QA report of a feature preview stuck at "2% Registering service worker",
through reloads, with no error: `navigator.serviceWorker.register()` had
neither resolved nor rejected, and the bootstrap pipeline waited on it with
no deadline.

Service worker jobs are serialised per scope. An install that never settles
holds the queue, and every later `register()`, `unregister()` and
`getRegistrations()` on that origin stays pending behind it until the
browser times the install out minutes later. Reproduced end to end: with a
worker whose install hangs, the next visit's `register()` never settles and
`unregister()` hangs with it — so the loading widget's own reset button,
which awaited `unregister()` before reloading, hung exactly when it was
needed.

What produces such an install here is the precache: `fetchWithRetry` bounds
the response headers and clears its abort timer the moment they arrive, so
`cache.put` then reads a body — `main.dart.js` is routinely ~10 MB — with no
deadline at all.

Three deadlines, one per hop:

- The whole SW step of the pipeline is capped (`SW_BOOTSTRAP_TIMEOUT_MS`,
  10s) and the app boots uncached if it expires. The abandoned registration
  keeps running, so the worker still installs once the browser frees the
  queue. This is not a new state: the pipeline already proceeds without a SW
  whenever activation times out.
- Precached bodies get an idle watchdog (`BODY_STALL_TIMEOUT_MS`, 30s) that
  cancels a stream which has stopped producing bytes. Idle time, not total
  duration, so a slow-but-alive connection still finishes however long it
  takes; a dead one fails one install attempt instead of blocking the origin.
- The reset action is bounded (`RESET_TIMEOUT_MS`, 3s) and always reloads.

Making the reset reachable also made its blast radius reachable, so it is
narrowed to the caching layer it is named for: application storage is no
longer cleared (on Flutter web `shared_preferences` is `localStorage`, so
clearing it signed the user out), and only the registration this bootstrap
owns is removed — unregistering everything also dropped push subscriptions
belonging to workers at unrelated scopes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Qkiw3K6ozvtWNHCTmFiAK
@mike-doctorina
mike-doctorina merged commit bac6f97 into master Sep 16, 2026
3 checks passed
@mike-doctorina
mike-doctorina deleted the fix/bootstrap-sw-registration-watchdog branch September 16, 2026 17:44
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.

1 participant