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.

Three-party delegation: page, service worker, compute worker The page requests a computed URL. The service worker's fetch handler asks the Cache API first and returns a stored Response in under a millisecond on a hit. On a miss it resolves the requesting client, posts the input together with one MessagePort, and the page relays the job to its own dedicated worker; the result comes back through port one, the service worker stores it and answers respondWith. A footnote records that the Worker constructor is not exposed to service-worker scope, which is why the page has to lend a thread. Page (window) Service Worker Cache Storage fetch('/api/compute') POST · ArrayBuffer body fetch handler event.respondWith() computed-v3 synthetic GET keys onmessage relay navigator.serviceWorker clients.get(clientId) new MessageChannel() compute.worker.js CPU-bound loop own thread · never idle-killed cache.put(key, res) event.waitUntil(work) keeps the instance alive 1 · request 8 · Response 2 · match(key) hit · < 1 ms miss 3 · postMessage {input, port2} 4 · pool.run(input) 5 · result buffer 6 · port1 reply 7 · store result × new Worker() is not available inside sw.js The Worker constructor is exposed to Window, DedicatedWorkerGlobalScope and SharedWorkerGlobalScope only — steps 3 to 6 exist because the service worker has to borrow a thread from a page it controls.
The service worker memoizes and routes; the page owns the thread that does the arithmetic. A hit ends at step 2; a miss walks the whole loop once and turns every later request into a hit.

Prerequisites

Confirm each of these before writing the fetch handler:

  • A secure origin. Service workers require HTTPS, or http://localhost during 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 SharedArrayBuffer anywhere in the pipeline. Everything in this guide works with transferable ArrayBuffers 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);
});
Trade-off: in-memory memoization vs. the Cache API

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());
}
Trade-off: fetch interception vs. direct postMessage

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' });
}
Trade-off: hashing cost vs. key precision

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;
}
Trade-off: freshness vs. instant answers

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;
}
Trade-off: borrowing a client's thread

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
};
Trade-off: one worker per job vs. a persistent pool

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' },
    });
  }
}
Trade-off: computing inline as a last resort

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;
      }
    }
  })());
});
Trade-off: best-effort pre-warming

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
  })());
});
Trade-off: skipWaiting vs. version consistency

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.

Two requests either side of an idle termination A sequence diagram with four lifelines: page, service worker, cache storage and compute worker. The first request misses the cache, is delegated to the page's compute worker over a MessagePort, costs roughly 240 milliseconds of worker CPU, and is stored. After about thirty seconds of idleness the browser terminates the service-worker instance and all module-level state is lost, but the cache entry survives, so the second identical request is answered from the Cache API in under a millisecond with no delegation at all. Page (window) Service Worker Cache Storage compute.worker.js register('/sw.js') · install → activate fetch #1 · POST /api/compute cache.match(key) miss client.postMessage({input}, [port2]) pool.run(input) · transferred ≈ 240 ms CPU event loop free result ArrayBuffer (transferred) port1.postMessage(result) cache.put(key, response.clone()) Response · ≈ 250 ms total ≈ 30 s idle — the browser terminates this service-worker instance module-level state is lost · the Cache API entry survives fetch #2 · identical input cache.match(key) HIT idle — never woken Response · < 1 ms · no delegation
The instance that answered the first request no longer exists when the second arrives. Only what reached the Cache API survives — which is precisely why the memo lives there and not in a module-level Map.

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 and cross-origin isolation in service-worker scope

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.

Where a 4 MB payload is copied and where it is only moved Horizontal bars showing the cost of each boundary in one round trip of a four-megabyte payload: the fetch request body is always copied at about eight milliseconds and the cache write costs about five milliseconds, while the four postMessage hops cost under a tenth of a millisecond each because the ArrayBuffer is listed for transfer. A final bar shows the same four hops costing roughly twenty milliseconds if the transfer list is forgotten and the buffer is structured-cloned instead. One 4 MB round trip — where the bytes are actually copied BOUNDARY MECHANISM COST FOR A 4 MB PAYLOAD Page → Service Worker fetch request body always copied ≈ 8 ms Service Worker → Page client.postMessage(job, [port2, input]) transferred < 0.1 ms Page → compute worker worker.postMessage(input, [input]) transferred < 0.1 ms compute worker → Page postMessage(out.buffer, [out.buffer]) transferred < 0.1 ms Page → Service Worker port1.postMessage(result, [result]) transferred < 0.1 ms Service Worker → Cache Storage cache.put(key, response.clone()) disk write ≈ 5 ms the same four hops, cloned what you pay for a forgotten transfer list structured clone ≈ 20 ms Two boundaries are unavoidable: the fetch body and the disk write. Everything between them is a pointer move — as long as it is in a transfer list.
Measured on a 4 MB 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.

What the three Performance lanes should look like Two timeline panels sharing three lanes. On the first pass, a cache miss, the main-thread lane holds only two short blocks and no long task, the service-worker lane shows a sub-millisecond match tick and a sub-millisecond put tick with a free event loop between them, and the compute-worker lane carries roughly 240 milliseconds of CPU. On the second pass, a cache hit drawn at a much shorter scale, only the service-worker match tick appears and the compute worker is never woken. Pass 1 — cache miss Pass 2 — cache hit Main thread Service Worker compute.worker.js idle — no long task anywhere in here match put awaiting the delegated reply — event loop free ≈ 240 ms CPU 0 150 ms 300 ms idle cache.match → Response · 0.6 ms never woken — no delegation 0 note the scale 3 ms Handler occupancy — the two service-worker ticks — stays under a millisecond on both passes. Delegation latency minus worker CPU time is the protocol overhead; above about 2 ms something is being cloned. A hit costs the same on every device, because it is a disk read rather than a computation.
The shape to look for in a Performance recording: CPU time confined to the worker lane, and the service-worker lane reduced to two ticks either side of an await.

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.

The fallback ladder, and what the user sees at each exit A vertical ladder of the checks a compute request passes through — cache lookup, resolving a client, waiting for the port reply, the worker result, the cache write, and respondWith settling. Each step has a branch to the right showing its failure exit: a cache hit returns instantly, a missing client falls back to the server, a timeout re-queues the job behind a 503, a worker error is flattened into a 5xx response, a quota error trims the cache and still answers, and a terminated instance is the only branch that reaches the user as a browser network-error page. the path a request takes the exit, and what the user sees caches.match(key) cache-first, on every request hit → the stored Response an instant result, no CPU spent anywhere hit miss clients.get(clientId) borrow a page-owned thread NoClientError → fetch(event.request) a slower answer from the server, still correct no client client found port reply within 10 s the only backstop for a closed tab timeout → re-queue + 503 Retry-After a retry a moment later, not a hung request no reply reply arrives worker result ok / error envelope { ok: false } → 5xx Response your own error UI, not a dead page worker threw bytes in hand cache.put(key, res) memoize for every later request QuotaExceededError → trim, still answer the correct result; the cache quietly shrinks quota spent stored respondWith settles the Response reaches the page instance killed mid-event → never settles the browser's network-error page — the one to avoid worker terminated
Every branch except the last one ends in something the user can act on. Bounding the job, catching 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.

Frequently Asked Questions

Can a service worker spawn a dedicated worker with new Worker()?
No. The Worker constructor is exposed only to Window, DedicatedWorkerGlobalScope, and SharedWorkerGlobalScope — it is not defined in ServiceWorkerGlobalScope, so new Worker() inside sw.js throws a ReferenceError in every engine. To get CPU work onto a dedicated worker, the service worker must ask a controlled page to run the job via client.postMessage() and receive the result back over a MessagePort.
Does a long computation in a service worker block the page's main thread?
Not directly — the service worker has its own thread and its own event loop, so a tight loop there does not stall rendering by itself. It blocks the service worker’s event loop, which means every other fetch the worker is registered to handle queues behind it. Since that usually includes navigations, scripts, and images, the page still stalls, just one indirection later.
How long can a service worker keep running after handling an event?
Chrome terminates an idle service worker after roughly 30 seconds with no pending events, and kills any single event whose waitUntil/respondWith promise has not settled after about 5 minutes. Devices under memory pressure — iOS especially — terminate far sooner. Keep any one delegated job under about 10 seconds and treat module-level variables as lost between events.
What happens on a cache miss when no page is open to run the computation?
clients.matchAll() returns an empty list, so there is nobody to delegate to. Three answers work: compute inline in the service worker if the job is small (accepting that it blocks other events), fall back to a server round-trip, or persist the job in IndexedDB and drain the queue the next time a client claims the worker. Always pick one explicitly — the default behaviour is a hung respondWith.

See also