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:

  1. new Worker() does not exist in service worker scope. The Worker constructor is exposed to Window, DedicatedWorkerGlobalScope, and SharedWorkerGlobalScope. In ServiceWorkerGlobalScope it is simply undefined, so a service worker cannot spawn a thread of its own. It can only borrow one from a page it controls.
  2. A service worker has exactly one event loop, shared by every client on the origin. A 600 ms transform inside respondWith does not block the main thread directly — but every fetch event 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.
Which worker type should run this work? A two-level decision tree. The root asks whether the loop runs longer than about fifty milliseconds, measured with performance.now() on mid-range hardware. If yes, the next question is whether the result is reused or served to a fetch request: yes leads to a dedicated worker with a service worker in front as a cache and delegator, no leads to a dedicated worker on its own. If the work is short, the next question is whether it is triggered by a request the service worker sees: yes leads to doing it inline in the fetch handler while keeping it under five milliseconds, no leaves it on the main thread unless it sits in a hot path. A footnote records that both left-hand outcomes put the loop on a dedicated worker, because the Worker constructor is not defined in service worker scope. Loop longer than ~50 ms? timed with performance.now() on mid-range hardware yes · CPU-bound no · short work Is the result reused, or served to a fetch? Triggered by a request the service worker sees? yes no yes no Dedicated worker + a service worker in front cache-first; delegate the miss over a MessagePort Dedicated worker only the page owns it and calls terminate() when done no caching layer needed Service worker handler do it inline in respondWith keep it under ~5 ms every tab queues behind it Main thread is fine under ~5 ms and not in a hot path; otherwise move it to a dedicated worker Both left-hand outcomes put the loop on a dedicated worker. The service worker never runs the loop — it decides whether the loop has to run at all, and new Worker() is not defined in its scope.
The routing rule in full: the length of the work picks the thread, and only then does reuse decide whether a service worker belongs in front of it.

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:

Lifetime: one continuous thread versus a fragmented, browser-controlled instance Two schematic tracks over about sixty seconds. The dedicated worker track is one unbroken bar from new Worker() to worker.terminate(): a thread of your own that nothing interrupts, where a long loop is invisible to every other context. The service worker track is a series of short bursts — install and activate, then a few fetch events — followed by roughly thirty seconds of idle after which the browser terminates the instance and all module-level state is lost. A later event pays a cold start of twenty to fifty milliseconds, and a six-hundred-millisecond transform inside respondWith holds the single event loop while three further fetch events queue behind it. Dedicated worker you own it new Worker() worker.terminate() / page unload one thread of your own — nothing interrupts it a 600 ms loop in here is invisible to every other context queued behind it: fetch fetch nav Service Worker the browser owns it install → activate 3 fetch events · ~2 ms each ≈ 30 s in Chrome; seconds on iOS under memory pressure no events — instance idle terminated module state lost cold start 20–50 ms 600 ms transform inside respondWith 0 s 10 20 30 40 50 60 s schematic time · one page open, then left alone
The dedicated worker's bar is unbroken because you control both ends of it. The service worker's is cut into event-sized pieces by a policy you do not set — and the one long piece stalls every other event on the origin.

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.

One hit and one miss, message by message A sequence diagram with four lifelines: the page, the service worker, cache storage and hash.worker.ts. On a hit the page's fetch reaches the service worker, cache.match returns the stored Response, and respondWith answers in two to five tenths of a millisecond with no page or worker involved. On a miss the service worker posts the input and one MessagePort to the requesting client, the page forwards the input to its dedicated worker with a transfer, and roughly one hundred and eighty milliseconds of CPU run on that worker's own thread while the service worker's event loop stays free for other fetch events. The result is transferred back through the port, stored with cache.put, and returned by respondWith, adding about one to three milliseconds of coordination on top of the computation. Page (window) Service Worker Cache Storage hash.worker.ts cache hit 1 · fetch('/api/histogram') 2 · cache.match(key) 3 · stored Response 4 · respondWith(hit) 0.2–0.5 ms, zero CPU no page or worker involved cache miss 5 · cache.match(key) 6 · miss → undefined 7 · client.postMessage({ input }, [channel.port2, input]) 8 · compute.postMessage(input, [input]) ≈ 180 ms CPU service worker: nothing to do other fetch events keep flowing through it 9 · result ArrayBuffer (transferred) 10 · port.postMessage(result, [result]) 11 · cache.put(key, response.clone()) 12 · respondWith(response) ≈ 1–3 ms of coordination on top of the CPU time
Steps 1–4 are the whole story on a hit. Steps 5–12 are the miss: the service worker borrows a thread it cannot create, and its own event loop is free for the 180 ms that matter.

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.

What each part of the split actually costs A horizontal bar chart on a logarithmic scale from a tenth of a millisecond to one thousand milliseconds. A Cache API hit costs 0.2 to 0.5 milliseconds; the delegation overhead of two transfers and a MessageChannel costs 1 to 3 milliseconds; spawning a dedicated worker costs 5 to 15 milliseconds; a service worker cold start costs 20 to 50 milliseconds; and the 10 MB histogram computation itself costs about 180 milliseconds. A dashed reference line marks the 16.7 millisecond frame budget, which only the cold start and the computation exceed. log scale · each gridline is 10× Cache API hit cache.match() → Response 0.2–0.5 ms Delegation overhead 2 transfers + a MessageChannel 1–3 ms Dedicated worker spawn new isolate + script parse 5–15 ms Service worker cold start when it is not already running 20–50 ms 10 MB histogram loop the computation itself ≈ 180 ms 16.7 ms frame budget 0.1 ms 1 ms 10 ms 100 ms 1000 ms
Only the last two bars cross a frame. Everything the delegation machinery costs sits an order of magnitude below the work it is moving off the wrong thread.

Frequently Asked Questions

Which worker type should run a long image-processing loop?
A dedicated worker, without exception. It runs on its own thread, lives as long as the page holds a reference, and is only stopped by worker.terminate(), self.close(), or the page going away. A service worker is terminated on the browser’s schedule — roughly 30 s of idle in Chrome, seconds on iOS under memory pressure — and while the loop runs it occupies the single event loop that every fetch event for the whole origin is queued behind.
Can a service worker create its own dedicated worker with new Worker()?
No. The Worker constructor is exposed to Window, DedicatedWorkerGlobalScope, and SharedWorkerGlobalScope only — it is not defined in ServiceWorkerGlobalScope, so new Worker() inside sw.js throws a ReferenceError in every engine. The service worker has to borrow a thread instead: resolve a controlled page with clients.get(), hand it the job plus a MessagePort, and let the page run the work on a worker it owns.
Is any computation acceptable inside a service worker event handler?
Short work is fine — hashing a cache key with crypto.subtle.digest, rewriting a URL, comparing versions, anything that finishes in single-digit milliseconds. The threshold is not about correctness but about queueing: every millisecond spent in a handler delays every other request the worker is registered for, including navigations. Past roughly 50 ms, move the work to a dedicated worker and let the service worker cache the result.

See also