Service Workers for Computation
A service worker is an event-driven network proxy, not a compute host — but it owns the two things a computation pipeline needs most: a persistent, origin-scoped result store and the ability to answer a request before it ever reaches the network. This guide is a specialisation of High-Performance Computation Patterns, and it covers the honest division of labour: the service worker memoizes and routes, a dedicated worker owned by the page does the arithmetic, and neither one ever runs a long loop where a fetch event is waiting on it.
The Bottleneck: A Fetch Handler That Computes Stalls the Whole Origin
The symptom shows up as a site that feels worse after the service worker ships. A user opens a report, the page requests /api/compute?series=q3, and for the next 600 ms nothing on the origin loads: not the lazy-loaded chart bundle, not the avatar images, not the next navigation. The main thread is idle in the trace — this is not classic main-thread jank — yet every network row in DevTools sits in a “queued” state with (ServiceWorker) in the Size column.
The cause is that a service worker has exactly one event loop, shared by every client it controls. A synchronous transform inside respondWith occupies that loop, and fetch events for unrelated URLs — from every open tab on the origin — wait their turn. Two independent limits then compound it:
| Limit | Chrome | Practical effect |
|---|---|---|
| Idle termination | ~30 s with no pending events | Module-level caches, warm lookup tables, and half-finished jobs vanish between requests |
| Maximum single-event lifetime | ~5 min for waitUntil / respondWith |
A job that outlives it is killed, and the page receives a network error, not a timeout you can catch |
| Memory-pressure termination | any time | On iOS the worker is commonly reclaimed within seconds of going idle |
The second trap is more surprising: a service worker cannot create a dedicated worker. In the HTML specification the Worker constructor is exposed to Window, DedicatedWorkerGlobalScope, and SharedWorkerGlobalScope only, so new Worker(...) inside sw.js throws a ReferenceError rather than giving you a second thread. Any design that assumes the service worker can push a loop onto a private thread of its own has to be restructured around a three-party protocol instead: the page owns the worker, the service worker owns the cache, and a MessagePort connects them. The worker-type decision itself is laid out in Dedicated vs Service Workers for CPU Tasks.
Prerequisites
Confirm each of these before writing the fetch handler:
- A secure origin. Service workers require HTTPS, or
http://localhostduring development. There is no flag that relaxes this in a production build. - A measurement that justifies caching. The pattern pays off when the same logical input is requested repeatedly. If every request has a unique input, you are building a delegation channel, not a cache, and a plain dedicated worker owned by the page is simpler.
- Comfort with the install/activate/fetch sequence and with
event.waitUntil. Registration timing matters: a page loaded before the first activation is not controlled and its requests bypass the worker entirely. - A dedicated worker on the page side. The service worker cannot create one, so the page must already run — or be able to run — a compute worker. Sizing and queue behaviour follow Worker Pool Management.
- A deterministic key function. Same logical input must produce the same cache key across releases, and a different key when the algorithm changes.
- A storage budget. Cache API storage counts against the origin quota; check
navigator.storage.estimate()before caching multi-megabyte results. - COOP/COEP only if you intend to use
SharedArrayBufferanywhere in the pipeline. Everything in this guide works with transferableArrayBuffers and needs no isolation headers.
Step-by-Step Implementation
1. Make the lifecycle visible before designing around it
Every assumption in this guide rests on the service worker being killed and re-evaluated behind your back. Prove it in your own app before you build state that depends on the opposite.
// sw.ts — lifecycle instrumentation
/// <reference lib="webworker" />
declare const self: ServiceWorkerGlobalScope;
// Re-initialised on every fresh evaluation: a useful termination detector.
const EVALUATED_AT = Date.now();
let handledEvents = 0;
console.log('[SW] script evaluated — this is a NEW instance');
self.addEventListener('activate', () => {
console.log(`[SW] activate ${Date.now() - EVALUATED_AT} ms after evaluation`);
});
self.addEventListener('fetch', (event: FetchEvent) => {
handledEvents++;
// If "script evaluated" prints again between two requests, the previous
// instance was terminated and every module-level value above was lost.
console.log(`[SW] fetch #${handledEvents}`, new URL(event.request.url).pathname);
});
A module-level Map is the fastest possible result cache — until the worker is terminated 30 seconds later and the map is gone. The Cache API costs a disk read (0.3–1 ms for small entries) but survives termination, browser restarts, and version upgrades. Use the map only as a same-event deduplication layer for concurrent identical requests, never as the durable store.
2. Register the worker and route computation through a URL
Computation becomes cacheable when it is addressed like a resource. Give each job a URL, and the fetch handler gets a natural interception point.
// main.ts — registration plus a typed client for computed results
export async function installComputeProxy(): Promise<ServiceWorkerRegistration> {
if (!('serviceWorker' in navigator)) throw new Error('service workers unsupported');
const reg = await navigator.serviceWorker.register('/sw.js', { scope: '/' });
// `ready` resolves once an ACTIVE worker controls this page — not merely installed.
await navigator.serviceWorker.ready;
return reg;
}
export async function computeSeries(seriesId: string, samples: Float64Array): Promise<Float64Array> {
const res = await fetch(`/api/compute?series=${encodeURIComponent(seriesId)}&v=3`, {
method: 'POST',
body: samples.buffer, // raw bytes, not JSON
headers: { 'Content-Type': 'application/octet-stream' },
});
if (!res.ok) throw new Error(`compute failed: ${res.status}`);
return new Float64Array(await res.arrayBuffer());
}
Routing through fetch keeps the calling code identical whether or not a service worker is installed — with no worker, the request reaches the server and still returns a correct answer. The cost is one extra hop and the loss of transferable objects on the request leg: a fetch body is always copied. Direct navigator.serviceWorker.controller.postMessage() avoids the copy but has no graceful no-worker fallback, so reserve it for the reply path where the payload is large.
3. Build a stable synthetic cache key
Using event.request as the key breaks in two ways: the Cache API refuses non-GET requests outright, and two URLs that differ only in a header (or in parameter ordering) become separate entries. Hash the logical input instead.
// sw.ts — one deterministic GET Request per logical job
const CACHE_NAME = 'computed-v3'; // bump on any algorithm change
const ALGO_VERSION = 'fft-hann-2'; // participates in the digest
async function buildCacheKey(seriesId: string, input: ArrayBuffer): Promise<Request> {
const digest = await crypto.subtle.digest('SHA-256', input);
const hex = [...new Uint8Array(digest).slice(0, 8)]
.map(b => b.toString(16).padStart(2, '0')).join('');
// A synthetic, always-GET URL: legal as a Cache API key and human-readable in DevTools.
return new Request(`/__computed/${ALGO_VERSION}/${seriesId}/${hex}`, { method: 'GET' });
}
crypto.subtle.digest('SHA-256', buf) runs at roughly 500 MB/s in Chromium, so a 4 MB input costs about 8 ms — occasionally more than a cheap computation. When the inputs are already identified by an id and a version, key on those directly and skip the digest; hash only when correctness depends on the bytes themselves. Never hash on the main thread for this purpose: do it inside the service worker or the compute worker.
4. Answer from the Cache API first
This is the part of the pipeline where a service worker is genuinely unbeatable: a stored Response is returned without any computation, on any device, at a cost that does not depend on CPU speed.
// sw.ts — cache-first, delegate on miss
self.addEventListener('fetch', (event: FetchEvent) => {
const url = new URL(event.request.url);
if (url.pathname !== '/api/compute') return; // let everything else pass through
// respondWith MUST be called synchronously inside the handler.
event.respondWith(handleCompute(event));
});
async function handleCompute(event: FetchEvent): Promise<Response> {
const url = new URL(event.request.url);
const seriesId = url.searchParams.get('series') ?? 'default';
const input = await event.request.arrayBuffer();
const cache = await caches.open(CACHE_NAME);
const key = await buildCacheKey(seriesId, input);
const hit = await cache.match(key);
if (hit) return hit; // sub-millisecond, zero CPU
const result = await delegateToClient(event.clientId, { key: key.url, input });
const response = new Response(result, {
headers: {
'Content-Type': 'application/octet-stream',
'X-Computed-By': 'worker',
'X-Algo-Version': ALGO_VERSION,
},
});
// Store a clone: a Response body is a single-use stream.
await cache.put(key, response.clone());
return response;
}
A cache-first handler returns yesterday's result forever unless something invalidates it. Encode the algorithm version in both the cache name and the key, as above, so a deploy that changes the maths cannot serve stale numbers; then evict old cache versions during activate. If results depend on server-side data that can change, add a stale-while-revalidate leg: return the hit immediately and schedule a recompute through event.waitUntil(). The eviction and versioning mechanics are covered in Caching Computed Results with the Cache API.
5. Delegate the miss to a page-owned dedicated worker
Since the service worker cannot create a thread, it borrows one. event.clientId identifies the page that made the request; a MessageChannel gives the two contexts a private reply path that does not collide with any other in-flight job.
// sw.ts — hand the job to a controlled page, await one reply
class NoClientError extends Error {}
async function delegateToClient(
clientId: string,
job: { key: string; input: ArrayBuffer },
timeoutMs = 10_000,
): Promise<ArrayBuffer> {
// The requesting client is preferred; any window client will do for sync events.
const client = (await self.clients.get(clientId))
?? (await self.clients.matchAll({ type: 'window' }))[0];
if (!client) throw new NoClientError('no controlled page available to compute');
const channel = new MessageChannel();
const reply = new Promise<ArrayBuffer>((resolve, reject) => {
const timer = setTimeout(() => {
channel.port1.close();
reject(new Error(`compute timed out after ${timeoutMs} ms`));
}, timeoutMs);
channel.port1.onmessage = (e: MessageEvent<
{ ok: true; result: ArrayBuffer } | { ok: false; name: string; message: string }
>) => {
clearTimeout(timer);
channel.port1.close();
if (e.data.ok) resolve(e.data.result);
else reject(Object.assign(new Error(e.data.message), { name: e.data.name }));
};
});
// port2 goes to the page; the input bytes are transferred, not copied.
client.postMessage({ type: 'compute', key: job.key, input: job.input },
[channel.port2, job.input]);
return reply;
}
Delegation adds two message hops (roughly 0.2–0.6 ms combined for a transferred buffer) and makes the response depend on a page staying open. If the user closes the tab mid-job, the port never replies and only your timeout saves the handler. The compensation is decisive: the loop runs on a thread the browser will not terminate for idleness, the service worker's event loop stays free for other fetches, and a pool on the page side can run several jobs in parallel.
6. Relay the job on the page and run it on the worker
The page’s role is purely a relay — it must not touch the payload, because doing so on the main thread reintroduces exactly the cost you moved away.
// main.ts — forward service-worker jobs to the compute pool
import { createWorkerPool } from './pool';
const pool = createWorkerPool(
new URL('./compute.worker.js', import.meta.url),
Math.max(1, (navigator.hardwareConcurrency ?? 4) - 1),
);
navigator.serviceWorker.addEventListener('message', (event: MessageEvent) => {
if (event.data?.type !== 'compute') return;
const port = event.ports[0]; // private reply channel
const { input } = event.data as { input: ArrayBuffer };
pool.run(input)
.then((result: ArrayBuffer) => port.postMessage({ ok: true, result }, [result]))
.catch((err: Error) =>
port.postMessage({ ok: false, name: err.name, message: err.message }));
});
// compute.worker.js — the only context allowed to block
self.onmessage = (e) => {
const input = new Float64Array(e.data);
// Mean-centre then window: a few hundred ms on 1M samples, harmless here
// because this thread owns nothing but this job.
let sum = 0;
for (let i = 0; i < input.length; i++) sum += input[i];
const mean = sum / input.length;
const out = new Float64Array(input.length);
const n = input.length - 1;
for (let i = 0; i < input.length; i++) {
const hann = 0.5 * (1 - Math.cos((2 * Math.PI * i) / n)); // Hann window
out[i] = (input[i] - mean) * hann;
}
self.postMessage(out.buffer, [out.buffer]); // zero-copy hand-back
};
Creating a worker per job costs 5–15 ms of module resolution and realm setup, which on small payloads exceeds the computation. A persistent pool amortises that to zero but holds memory (each worker realm is roughly 1–2 MB before your code loads) and needs a queue policy for bursts. Size it from navigator.hardwareConcurrency and cap the queue — see Worker Pool Management for the drain and back-pressure rules.
7. Keep the worker alive, and define what happens without a client
respondWith extends the service worker’s life only until it settles; waitUntil extends it independently. Use both, and decide explicitly what a miss means when no page can help.
// sw.ts — bounded lifetime plus an explicit fallback ladder
async function handleComputeResilient(event: FetchEvent): Promise<Response> {
const cache = await caches.open(CACHE_NAME);
const input = await event.request.arrayBuffer();
const key = await buildCacheKey(new URL(event.request.url).searchParams.get('series') ?? '', input);
const hit = await cache.match(key);
if (hit) return hit;
try {
const work = delegateToClient(event.clientId, { key: key.url, input: input.slice(0) });
event.waitUntil(work); // survive while the page computes
const result = await work;
const response = new Response(result, { headers: { 'Content-Type': 'application/octet-stream' } });
event.waitUntil(cache.put(key, response.clone()));
return response;
} catch (err) {
if (err instanceof NoClientError) {
// No page to borrow: ask the server rather than blocking this event loop.
return fetch(event.request);
}
return new Response(JSON.stringify({ error: (err as Error).message }), {
status: 503,
headers: { 'Content-Type': 'application/json', 'Retry-After': '1' },
});
}
}
When there is no client and no server endpoint, the only remaining option is to run the job in the service worker itself. That is defensible for work measured in single-digit milliseconds, or for WebAssembly kernels where the same job is several times cheaper — see WebAssembly in Workers. It is indefensible for anything longer, because every navigation on the origin waits behind it. If you take this path, chunk the loop and yield with await new Promise(r => setTimeout(r, 0)) between chunks so other events can interleave.
8. Pre-warm the cache with background sync
Background Sync fires a sync event once the device has connectivity, which is an opportunity to compute results the user has not asked for yet — turning their next interaction into a pure cache hit.
// main.ts — schedule a pre-warm
export async function schedulePrewarm(seriesId: string): Promise<void> {
const reg = await navigator.serviceWorker.ready;
if (!('sync' in reg)) return; // Firefox and Safari: no-op
await storePrewarmJob(seriesId); // IndexedDB: the SW reads it later
await (reg as ServiceWorkerRegistration & { sync: SyncManager })
.sync.register('prewarm-compute');
}
// sw.ts — drain the pre-warm queue
self.addEventListener('sync', (event: SyncEvent) => {
if (event.tag !== 'prewarm-compute') return;
event.waitUntil((async () => {
const cache = await caches.open(CACHE_NAME);
for (const job of await loadPrewarmJobs()) {
const key = await buildCacheKey(job.seriesId, job.input);
if (await cache.match(key)) continue; // already warm
try {
const result = await delegateToClient('', { key: key.url, input: job.input });
await cache.put(key, new Response(result, {
headers: { 'Content-Type': 'application/octet-stream' },
}));
await clearPrewarmJob(job.id);
} catch {
// Leave the job queued; the next sync (or the next client) retries it.
break;
}
}
})());
});
A sync event may fire seconds or many minutes after registration, and Firefox and Safari do not implement Background Sync at all — as of mid-2026 it remains a Chromium-only API. Treat every pre-warm as an optimisation that may never run: never make a user-visible flow depend on it, and always keep the on-demand path from step 4 as the source of truth. On a sync event there is usually no window client either, so pre-warming is the case where the fallback ladder in step 7 matters most.
9. Precache results that are known at build time
The cheapest computation is the one that already happened on your build machine. Lookup tables, compiled WebAssembly binaries, and reference datasets belong in the install handler.
// sw.ts — install-time precache and activate-time eviction
const PRECACHE_URLS = [
'/assets/gamma-lut.bin', // 64 KB LUT, ~700 ms to derive at runtime
'/assets/reference-vectors.bin',
'/assets/transform.wasm',
];
self.addEventListener('install', (event: ExtendableEvent) => {
event.waitUntil((async () => {
const cache = await caches.open(CACHE_NAME);
await cache.addAll(PRECACHE_URLS); // atomic: any 404 rejects the install
await self.skipWaiting();
})());
});
self.addEventListener('activate', (event: ExtendableEvent) => {
event.waitUntil((async () => {
const names = await caches.keys();
await Promise.all(names.filter(n => n !== CACHE_NAME).map(n => caches.delete(n)));
await self.clients.claim(); // control existing tabs immediately
})());
});
skipWaiting() plus clients.claim() activates the new worker under pages that were loaded by the old one. That is exactly what you want for a bug fix, and exactly what you do not want when the new worker's cache keys or response shapes differ — an open tab can then receive results it cannot parse. Either keep response formats backward compatible, or drop skipWaiting and let the new version take over on the next navigation.
Data-Transfer Strategy: Clone, Transfer, or Share
Bytes cross three different boundaries in this pipeline, and each has its own rules. Getting them wrong reintroduces main-thread copies in a design that was supposed to remove them.
| Boundary | Mechanism available | Cost | Guidance |
|---|---|---|---|
Page → service worker via fetch body |
Always copied by the network stack | ~1–3 ms per MB | Send bytes (ArrayBuffer, not JSON) and keep request payloads modest |
Service worker → client via client.postMessage |
Structured clone, transfer list supported | O(1) for a transferred ArrayBuffer, <0.5 ms |
Always list the buffer in the transfer array; the sender’s copy detaches |
Client → compute worker via worker.postMessage |
Structured clone, transfer list supported | O(1) transferred, ~2–4 ms per MB cloned | Transfer in both directions; return columnar typed arrays, not object graphs |
| Any context → Cache API | Serialized to disk-backed storage | 0.3–1 ms small entries, ~5 ms for 2 MB | Store binary bodies; JSON of a million floats is ~5× the bytes and adds parse cost |
Between contexts via SharedArrayBuffer |
Requires cross-origin isolation | No copy | Not a fit here — see the callout below |
The dominant rule is that everything crossing a postMessage boundary in this pipeline should be an ArrayBuffer in a transfer list. A 4 MB Float64Array structured-clones in roughly 3–6 ms per hop and transfers in under 0.1 ms, and with two hops each way the difference decides whether delegation is worth doing at all. The ownership semantics — detachment, byteLength === 0 on the sender, and the errors you get for reading a detached buffer — are covered in Transferable Objects & Zero-Copy. When the payload is a parsed structure rather than numbers, reduce it before it crosses: the reduction techniques in Data Parsing & Serialization apply unchanged, and a service worker makes them stickier by memoizing the reduced form.
SharedArrayBuffer exists only in a cross-origin isolated context, which requires Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp on the document — and, for the worker itself, a COEP header on the service-worker script. Even where the browser grants it (Chromium only in practice), shared memory is the wrong tool at this boundary: a SharedArrayBuffer cannot be stored in the Cache API, and a service worker that may be terminated between events must never hold a lock that another thread is waiting on. Feature-detect with self.crossOriginIsolated and keep shared memory inside the page's own worker pool, where the ordering rules in SharedArrayBuffer & Atomics apply.
Float64Array. The four delegation hops only stay free while every buffer is listed for transfer; drop the list and the protocol overhead alone outweighs the cache write.Verification & Measurement
Three numbers tell you whether the design is working, and they fail independently.
// sw.ts — instrument hit rate, delegation latency, and handler occupancy
async function instrumentedCompute(event: FetchEvent): Promise<Response> {
const t0 = performance.now();
const cache = await caches.open(CACHE_NAME);
const input = await event.request.arrayBuffer();
const key = await buildCacheKey('series', input);
const hit = await cache.match(key);
if (hit) {
console.log(`[SW] HIT ${(performance.now() - t0).toFixed(2)} ms`);
return hit;
}
const tDelegate = performance.now();
const result = await delegateToClient(event.clientId, { key: key.url, input });
const delegateMs = performance.now() - tDelegate;
const response = new Response(result, { headers: { 'Content-Type': 'application/octet-stream' } });
await cache.put(key, response.clone());
console.log(`[SW] MISS total ${(performance.now() - t0).toFixed(2)} ms ` +
`(delegation ${delegateMs.toFixed(2)} ms, key+cache ${(performance.now() - t0 - delegateMs).toFixed(2)} ms)`);
return response;
}
Cache-hit latency should sit between 0.3 ms and 1 ms for entries under a few hundred kilobytes, and stay flat as device CPU varies — if it scales with payload size, you are storing JSON and paying to re-parse it. Delegation latency minus worker CPU time is the protocol overhead; anything above about 2 ms means a payload is being cloned instead of transferred somewhere along the chain. Handler occupancy — the time the service worker’s own event loop is busy rather than awaiting — is the number that protects the rest of the origin, and it should stay under a millisecond on every path.
In DevTools, confirm the same story visually. Application → Service Workers shows the instance status and a “stopped” state you can force to test cold starts. Application → Cache Storage should contain computed-v3 with your synthetic /__computed/... keys after the first miss. In a Performance recording, the main thread must show no long task, the service worker lane only short ticks, and the CPU time must appear in the compute worker’s own lane — the technique for reading those lanes is in Chrome DevTools Worker Debugging. Finally, in the Network panel a served hit reports (ServiceWorker) in the Size column with a duration under 2 ms.
Correctness needs its own check: compute the same fixture through the delegated path and through a direct in-page call, then compare the bytes. Float64Array results should match exactly — if they do not, a .slice() boundary or a detached-buffer read is corrupting the input before the worker sees it.
Failure Modes & Error Handling
No controlled client. clients.get(event.clientId) returns undefined for navigation requests (the client does not exist yet) and for sync events fired with every tab closed. Without the fallback ladder from step 7, the promise passed to respondWith rejects and the user sees a browser network-error page rather than your UI. Always branch on NoClientError explicitly.
The service worker is terminated mid-delegation. If the borrowed thread takes longer than the browser’s per-event ceiling, the instance is killed and respondWith never settles. Bound every job with the timeout in step 5, keep single jobs under ~10 seconds, and split larger work into cacheable segments so a retry resumes rather than restarts.
The Cache API rejects the key. cache.put() throws TypeError for a POST request, for a response with status 206, and for opaque cross-origin responses. The synthetic GET key in step 3 avoids the first; construct results with new Response(...) yourself rather than caching a fetched cross-origin body to avoid the others.
Storage quota exhaustion. cache.put() rejects with QuotaExceededError once the origin’s budget is spent, and the default behaviour is that the whole handler fails even though the result is in hand. Catch it, return the response anyway, and trim the cache out of band:
try {
await cache.put(key, response.clone());
} catch (err) {
if ((err as DOMException).name === 'QuotaExceededError') {
const keys = await cache.keys();
// Oldest-inserted first: cache.keys() preserves insertion order.
await Promise.all(keys.slice(0, Math.ceil(keys.length / 4)).map(k => cache.delete(k)));
} else {
throw err;
}
}
The compute worker throws. An Error does not survive structured clone with its prototype or stack intact, so the page’s relay must flatten it into { ok: false, name, message } before posting — which is what step 6 does. Reconstruct it on the service-worker side and map it to a 5xx Response so the calling code gets a meaningful failure. The full serialization pattern, including cause chains, is in Structured Error Serialization Across Threads.
Detached input on retry. After transferring the request bytes to the client, the service worker’s own ArrayBuffer has byteLength === 0. Any retry path must therefore transfer a copy (input.slice(0)) and keep the original, or re-read the body from the request — which is itself single-use. Decide this before writing the retry, not after the first TypeError.
A closed tab mid-job. The port simply goes quiet; there is no close event you can rely on. The timeout is the only backstop, and after it fires the job should be re-queued rather than reported as a permanent failure. General recovery structure for these cases is in Error Handling & Crash Recovery.
Silent version skew. A tab controlled by the previous service worker can receive a response body written by the new one. Version the payload (X-Algo-Version above), and have the client reject a version it does not understand instead of misreading the bytes.
QuotaExceededError, and flattening worker errors is what keeps the last branch unreachable.Browser Compatibility
| Feature | Chrome | Firefox | Safari | Edge |
|---|---|---|---|---|
| Service Worker (register, install, activate, fetch) | 40 | 44 | 11.1 | 17 |
CacheStorage / Cache |
40 | 41 | 11.1 | 17 |
Clients.matchAll() / client.postMessage() |
42 | 44 | 11.1 | 17 |
MessageChannel + port transfer to a client |
40 | 44 | 11.1 | 17 |
new Worker() inside service-worker scope |
Not supported | Not supported | Not supported | Not supported |
| Background Sync (one-shot) | 49 | Not supported | Not supported | 79 |
| Periodic Background Sync | 80 | Not supported | Not supported | 80 |
| Navigation preload | 59 | 99 | 15.4 | 79 |
| WebAssembly in service-worker scope | 57 | 52 | 11 | 16 |
self.crossOriginIsolated in service-worker scope |
92 | Not supported | Not supported | 92 |
Two rows deserve emphasis. The new Worker() row is not a gap in any one engine — the constructor is simply not exposed to ServiceWorkerGlobalScope in the specification, so delegation to a client is the portable design rather than a workaround. And Background Sync remains Chromium-only, which means pre-warming must always be optional behaviour layered on top of a working on-demand path.
Safari’s implementation is the one to test against directly: its worker instances are reclaimed aggressively on iOS, so cold starts are common and any assumption about warm module state fails there first. Firefox supports the delegation protocol in full but has no background sync, so it exercises the step 7 fallback ladder on every miss that arrives without a client.
For the decision that comes before all of this — whether a given CPU task belongs in a dedicated worker, a service worker, or neither — work through Dedicated vs Service Workers for CPU Tasks, and see Main Thread vs Worker Thread Lifecycle for how the page-side worker’s own lifetime interacts with navigation.