Can a Service Worker finish in-flight work before a new version takes control?
0 reputation · 18 Sept 2020, 04:59 UTC
0 reputation · 18 Sept 2020, 04:59 UTC
I'm deploying a small PWA where the Service Worker handles fetch caching and a long-running Background Sync task. When a new SW version ships, it installs in parallel but normally waits for all clients of the old worker to close. Using skipWaiting() with clients.claim() forces immediate activation, which is attractive for zero-downtime updates — but my concern is what happens to fetch handlers, sync tasks, or IndexedDB transactions the old worker started.
From what I can tell, the spec offers no drain or quiesce primitive: no way to ask the old worker to finish critical work before yielding, and no standard API to query how many clients it still controls or whether it is idle. Libraries like Workbox wrap the update flow, but they still rely on voluntary cooperation rather than an atomic cutover.
Assume current stable Chrome, Firefox, and Safari, whose controllerchange timing reportedly differs.
Is there any documented mechanism — or a reliable postMessage-based handshake pattern — for the new worker to wait until the old one reports no in-flight operations before calling skipWaiting()? And if the new worker fails activation after claiming clients, what is the expected recovery behavior for those tabs?
33275 reputation · 18 Sept 2020, 09:55 UTC
No — there is no drain or quiesce primitive in the Service Worker spec. skipWaiting() only shortens the waiting phase; nothing asks the old worker to finish its fetch handlers, Background Sync tasks, or IndexedDB transactions, and there is no standard API to query its in-flight count or idle state. The reliable approach is a cooperative postMessage handshake that you build yourself.
The old worker is not killed mid-task the instant the new one activates. Extendable events keep it alive via waitUntil() until pending promises settle. But once the new worker controls the clients, the old worker stops receiving new events — so a long sync task may finish in a worker that no longer owns anything, which is usually fine for idempotent writes and dangerous for anything that assumes continued control.
The standard cooperative pattern looks like this:
fetch/sync handlers, decrement on settle. IndexedDB transactions can't be paused mid-transaction, so check the counter only at transaction boundaries.install, messages clients (which relay to the old worker) with a drain-request.draining flag, stops accepting new logical work, waits for the counter to hit zero, then replies drained.skipWaiting().// old worker
let inFlight = 0, draining = false;
self.addEventListener('message', async (e) => {
if (e.data === 'drain-request') {
draining = true;
while (inFlight > 0) await new Promise(r => setTimeout(r, 50));
e.source.postMessage('drained');
}
});This is voluntary cooperation, not an atomic cutover: a tab opened mid-deploy can still be claimed by the new worker while old tabs run the old one, so your fetch handlers still need version-mismatch tolerance.
If the new worker throws during activate or its script fails evaluation, the browser discards it. A still-alive old worker keeps controlling its clients — tabs are not left controller-less. If the old worker was already terminated, clients become uncontrolled until reload; there is no automatic rollback to a previous version.
Because controllerchange timing differs across Chrome, Firefox, and Safari, make the handshake event-driven, never timeout-based. Test with an instrumented build: log the in-flight counter, trigger an update mid-sync, and confirm in DevTools (Chrome: Application > Service Workers; Firefox: about:debugging) whether the old worker's tasks settle before controllerchange fires. Simulate failure by throwing in the new worker's activate handler and checking whether existing tabs retain the old controller. One caveat worth flagging for review: exact worker termination heuristics are browser-specific and shift between releases, so re-verify on each major browser update.
Use comments to ask for clarification. Post a solution as an answer.
No question comments on this page.