Dedicated vs Service Workers for CPU Tasks
Two off-main-thread execution contexts, one CPU-bound job, and only one right answer: this page settles which worker type should run the loop, and how to combine both when the result also needs caching.
The choice is the first decision in the pipeline assembled in Service Workers for Computation, which is itself part of the High-Performance Computation Patterns reference. Both worker types run off the main thread, both are script-only contexts with no DOM, and both talk over postMessage — which is where the resemblance ends. Their lifecycles, their threading models, and the APIs exposed to them differ enough that swapping one for the other produces a site that intermittently fails under load rather than one that is merely slower.
The Decision in One Rule
A CPU-bound loop longer than about 50 ms runs on a dedicated worker. Always. A service worker’s job is to decide whether that loop needs to run at all.
A dedicated worker is a computation thread you own explicitly: you create it, you keep a reference, you terminate it. A service worker is an event-driven network proxy that the browser starts when an event arrives and kills when it judges the worker idle. It is the only context that can intercept fetch, and it holds a persistent, origin-scoped CacheStorage. Those are complementary capabilities, not competing ones — the failure mode is treating the proxy as a compute host.
Two structural facts follow from the specification rather than from any browser’s policy, and they decide most cases on their own:
new Worker()does not exist in service worker scope. TheWorkerconstructor is exposed toWindow,DedicatedWorkerGlobalScope, andSharedWorkerGlobalScope. InServiceWorkerGlobalScopeit is simply undefined, so a service worker cannot spawn a thread of its own. It can only borrow one from a page it controls.- A service worker has exactly one event loop, shared by every client on the origin. A 600 ms transform inside
respondWithdoes not block the main thread directly — but everyfetchevent from every open tab, including navigations and script loads, waits behind it. The trace shows an idle main thread and a stalled network panel, which is why this failure is usually misdiagnosed.
Capability Comparison
| Dimension | Dedicated Worker | Service Worker |
|---|---|---|
| Primary purpose | CPU-bound computation off the main thread | Network proxy, caching, background sync |
| Lifetime | As long as the page holds a reference; ends on worker.terminate(), self.close(), or page unload |
Started per event, terminated after ~30 s idle in Chrome; a single event may not exceed ~5 min |
| Termination risk mid-task | None | Real — memory pressure can reclaim the worker, and iOS does so within seconds |
| Threading model | One thread per instance, scheduled independently | One event loop shared by every controlled client of the origin |
Can create a Worker |
Yes — nested workers are supported | No — the constructor is not exposed in this scope |
| DOM access | No | No |
fetch interception |
No | Yes — the only context that can |
CacheStorage |
Available, but the API is asynchronous and unversioned by you | The natural owner of the result store |
SharedArrayBuffer |
Full support on a cross-origin-isolated page, including Atomics.wait |
Can receive and read one when isolated, but the agent cannot block, so Atomics.wait throws |
| Module scripts | new Worker(url, { type: 'module' }) in all current engines |
register(url, { type: 'module' }) in Chrome 91+ and Safari 16.4+; not in Firefox as of mid-2026 |
| Startup cost | 5–15 ms per instance (isolate plus script parse) | 0 ms when already running, 20–50 ms cold |
| Sweet spot | Parsing, transforms, image processing, WebAssembly, physics, inference | Memoized responses, offline support, request deduplication, background pre-warming |
The SharedArrayBuffer row is the one most often stated wrongly. A service worker on a cross-origin-isolated origin does get crossOriginIsolated === true and can hold a live view over shared memory posted to it. What it cannot do is block: like the window agent, the service worker agent has [[CanBlock]] set to false, so Atomics.wait throws a TypeError. Any producer/consumer handshake that parks a thread — the pattern behind SharedArrayBuffer & Atomics coordination — needs a dedicated worker on at least one end.
The two rows that decide the most arguments are Lifetime and Termination risk, and they are easier to see on a clock than in a table:
Minimal Reproducible Example
The smallest correct split: the page owns the compute thread, the service worker owns the cache and the routing, and a MessageChannel joins them for exactly one job. Three short files.
// hash.worker.ts — dedicated worker: the only file allowed to run a long loop
self.onmessage = (e: MessageEvent<ArrayBuffer>) => {
const bytes = new Uint8Array(e.data);
const histogram = new Float64Array(256);
for (let i = 0; i < bytes.length; i++) histogram[bytes[i]] += 1; // stands in for real CPU work
// Transfer the result buffer instead of copying it.
(self as DedicatedWorkerGlobalScope).postMessage(histogram.buffer, [histogram.buffer]);
};
// main.ts — the page creates the worker and answers delegation requests
const compute = new Worker(new URL('./hash.worker.ts', import.meta.url), { type: 'module' });
navigator.serviceWorker.addEventListener(
'message',
(event: MessageEvent<{ input: ArrayBuffer }>) => {
const [port] = event.ports; // private reply path for this job only
compute.addEventListener('message', function once(e: MessageEvent<ArrayBuffer>) {
compute.removeEventListener('message', once);
port.postMessage(e.data, [e.data]); // hand ownership back to the service worker
port.close();
});
compute.postMessage(event.data.input, [event.data.input]);
},
);
await navigator.serviceWorker.register('/sw.js');
await navigator.serviceWorker.ready; // resolves only once an ACTIVE worker controls this page
// sw.ts — service worker: caches, routes, and never computes
/// <reference lib="webworker" />
declare const self: ServiceWorkerGlobalScope;
const CACHE = 'histogram-v1';
self.addEventListener('fetch', (event: FetchEvent) => {
if (new URL(event.request.url).pathname !== '/api/histogram') return;
event.respondWith(serve(event)); // respondWith must be called synchronously
});
async function serve(event: FetchEvent): Promise<Response> {
const input = await event.request.arrayBuffer();
const cache = await caches.open(CACHE);
const key = await cacheKey(input);
const hit = await cache.match(key);
if (hit) return hit; // sub-millisecond, zero CPU, no page needed
const result = await delegate(event.clientId, input);
const response = new Response(result, {
headers: { 'Content-Type': 'application/octet-stream' },
});
await cache.put(key, response.clone()); // clone: a Response body is a single-use stream
return response;
}
/** Deterministic, always-GET key — the Cache API refuses POST requests as keys. */
async function cacheKey(input: ArrayBuffer): Promise<Request> {
const digest = await crypto.subtle.digest('SHA-256', input);
const hex = [...new Uint8Array(digest, 0, 8)]
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
return new Request(`/__computed/histogram-v1/${hex}`);
}
/** Borrow the page's thread: there is no `new Worker()` in this scope. */
async function delegate(clientId: string, input: ArrayBuffer, timeoutMs = 10_000): Promise<ArrayBuffer> {
const client = await self.clients.get(clientId);
if (!client) throw new Error('no controlled page available to compute');
const channel = new MessageChannel();
const reply = new Promise<ArrayBuffer>((resolve, reject) => {
channel.port1.onmessage = (e: MessageEvent<ArrayBuffer>) => resolve(e.data);
setTimeout(() => reject(new Error(`compute timed out after ${timeoutMs} ms`)), timeoutMs);
});
client.postMessage({ input }, [channel.port2, input]);
return reply;
}
Step-by-Step Walkthrough
The loop lives in hash.worker.ts, and nowhere else. That file is the only one with an unbounded iteration. It runs on its own thread with no lifetime policy attached to it: nothing in the browser will interrupt it at 30 seconds, and it can be reused for the next job without paying startup again.
postMessage(histogram.buffer, [histogram.buffer]) transfers rather than copies. The second argument is the transfer list, so ownership of the bytes moves and the sender’s view is neutered. On a 10 MB result this is the difference between roughly 12 ms of clone time and about 0.05 ms — the mechanics are covered in Transferable Objects & Zero-Copy.
main.ts is a relay, not a worker host in disguise. It listens on navigator.serviceWorker for delegation requests, forwards the input to the dedicated worker, and posts the answer back through event.ports[0]. The main thread touches each message for microseconds and never runs the arithmetic itself. In production this relay hands the job to a pool rather than a single instance — sizing and queueing follow Worker Pool Management.
respondWith is called synchronously; the awaiting happens inside serve. Calling it after an await throws InvalidStateError, because by then the event has already been dispatched to the network.
cacheKey hashes the logical input. Using event.request directly fails twice: the Cache API rejects non-GET requests as keys, and two requests differing only by a header collapse into the same entry. A synthetic GET URL containing an algorithm version and a digest is stable across releases and readable in DevTools. The versioning and eviction side of this is developed in Caching Computed Results with the Cache API.
delegate is where the borrowed thread appears. self.clients.get(event.clientId) resolves the page that made the request; client.postMessage({ input }, [channel.port2, input]) transfers both the input bytes and one end of a fresh channel in a single message. The channel matters because a plain client.postMessage reply would be broadcast to a shared handler with no way to correlate concurrent jobs. The timeout matters because a page that navigates away mid-job otherwise leaves respondWith hanging until the browser kills the event minutes later.
Gotchas and Edge Cases
Termination budgets are not portable. Chrome allows roughly 30 seconds of idle before shutting a service worker down and caps a single event at about 5 minutes. Safari on iOS reclaims workers within seconds under memory pressure. Never encode a fixed budget in your design: treat every module-level variable as lost between events, and keep any delegated job under about 10 seconds so a mid-flight kill is survivable.
A cache miss with no open page has no thread to borrow. clients.get() returns undefined for a sync or push event, and clients.matchAll() can be empty. Decide explicitly what happens: compute inline only if the job is genuinely small, fall back to a server round-trip, or queue the job in IndexedDB and drain it when a client next claims the worker. The default behaviour — an unresolved promise inside respondWith — surfaces to the page as a network error minutes later.
Spawning a worker per cache miss costs 5–15 ms every time. On a cold first visit with a high miss rate that overhead is paid on every request. Keep one long-lived worker (or a pool) alive in the page and reuse it; the delegation channel is cheap, the isolate is not.
Module support diverges between the two types. new Worker(url, { type: 'module' }) works everywhere current. A module service worker — register(url, { type: 'module' }) — works in Chrome 91+ and Safari 16.4+ but not in Firefox as of mid-2026, so ship a bundled classic script for the service worker even when the dedicated worker is an ES module.
Cross-origin isolation does not make the service worker a blocking agent. Setting Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp gives you SharedArrayBuffer in service worker scope, but Atomics.wait still throws there. Use Atomics.waitAsync where supported, or keep the blocking side of the handshake in a dedicated worker.
Performance Note
Measured on Chrome 124-class hardware with a 10 MB input, the split above costs about 1–3 ms of coordination overhead per cache miss: two transferred postMessage hops at roughly 0.05 ms each, MessageChannel construction at well under 0.1 ms, and a clients.get() lookup in the low hundreds of microseconds. A cache hit short-circuits all of it and returns a stored Response in 0.2–0.5 ms, independent of how expensive the original computation was.
The rule of thumb that falls out: delegation pays for itself once the computation exceeds roughly 5 ms, and caching pays for itself once the same input recurs even twice. Below both thresholds — a URL rewrite, a version comparison, a header check — do the work inline in the handler and skip the machinery entirely. Above 50 ms, there is no threshold to weigh: the loop goes on a dedicated worker, and the only remaining question is whether a service worker sits in front of it to stop the loop from running twice.