Dedicated vs Service Workers for CPU Tasks

A decision guide for choosing the right worker type when CPU work is involved. Both live off the main thread, but their lifecycle, threading model, and intended use-cases diverge sharply.

This page is part of the Service Workers for Computation reference, which is itself a section of High-Performance Computation Patterns.

The Minimal Reproducible Decision

Before comparing in depth, here is the shortest possible rule:

If you need to run a CPU loop that takes more than ~50ms, use a dedicated worker. Always.

Service workers are event-driven network proxies. They are designed to be killed between events. A dedicated worker is a persistent computation thread you control explicitly. These are complementary, not competing, tools.

Comparison Table

Dimension Dedicated Worker Service Worker
Primary purpose CPU-bound computation, off-main-thread logic Network proxy, caching, background sync
Lifecycle Lives as long as the page holds a reference; terminated by worker.terminate() or self.close() Launched on demand per event; terminated by browser after idle timeout (~30 s in Chrome)
Termination risk None during a running task Yes — browser can kill SW mid-task under memory pressure
Threading model Dedicated OS thread per worker instance Shared event loop across all controlled clients of an origin
DOM access No No
fetch interception No Yes — only SW can intercept fetch events
CacheStorage access No (use IndexedDB or postMessage to share) Yes — full read/write access
SharedArrayBuffer Supported (requires COOP/COEP) Not supported — SW scope does not get cross-origin isolation
Module worker support new Worker(url, { type: 'module' }) — all major browsers Module SW (type: 'module') — Chrome 91+, no Firefox
Max sustained CPU time Unbounded (your hardware limit) Practical cap ~10–30 s before termination risk
Spawn cost 5–15 ms (isolate + script parse) Varies; SW may already be running (0 ms) or cold-start (20–50 ms)
Communication with page postMessage / MessageChannel postMessage via client.postMessage(), or via a MessageChannel
Use-case sweet spot Parsing, transforms, image processing, WASM execution, physics, ML inference Pre-caching, request deduplication, offline support, background sync, serving precomputed responses

Walkthrough: When Each Type Applies

Dedicated worker: right for sustained CPU loops

A dedicated worker is the correct choice any time you have computation that:

  • takes more than ~50ms on a mid-range device,
  • runs as a tight loop over large data (parsing, sorting, FFTs, matrix multiply),
  • may need to run concurrently alongside other computation (pools of workers), or
  • requires SharedArrayBuffer for lock-free coordination.

The dedicated worker runs on its own OS thread. V8 schedules it independently of the main thread and independently of any service worker. You can run it for minutes without any risk of browser-initiated termination — the only way it stops is if you call worker.terminate(), the page is unloaded, or the worker calls self.close().

// main.ts — Dedicated worker for a 2M-row CSV parse
const worker = new Worker(new URL('./csv-parser.worker.ts', import.meta.url), {
  type: 'module',
});

worker.onmessage = (e: MessageEvent<{ rows: number; durationMs: number }>) => {
  console.log(`Parsed ${e.data.rows} rows in ${e.data.durationMs.toFixed(1)} ms`);
};

const csv = await fetch('/data/large.csv').then(r => r.arrayBuffer());
// Transfer the buffer zero-copy so main thread never blocks on the copy
worker.postMessage({ csv }, [csv]);
// csv-parser.worker.ts
self.onmessage = (e: MessageEvent<{ csv: ArrayBuffer }>) => {
  const t0 = performance.now();
  const text = new TextDecoder().decode(e.data.csv);
  const rows = text.split('\n').length - 1; // simplified; real parser would iterate
  self.postMessage({ rows, durationMs: performance.now() - t0 });
};

Service worker: right for cache-first response routing

A service worker is the correct choice when you need to:

  • return a previously computed result without re-running the computation,
  • intercept a network request before it reaches the server,
  • pre-warm the computation cache during install or via Background Sync,
  • deduplicate identical in-flight computation requests across multiple open tabs.

The SW does not replace a dedicated worker — it wraps one:

// sw.ts — SW as cache + coordinator, not as CPU worker
self.addEventListener('fetch', (event: FetchEvent) => {
  if (!event.request.url.includes('/api/parse')) return;

  event.respondWith(
    (async () => {
      const cache = await caches.open('parse-cache-v1');
      const hit = await cache.match(event.request);
      if (hit) return hit; // already computed, return instantly

      // Delegate the actual CPU work to a dedicated worker
      const body = await event.request.arrayBuffer();
      const result = await runInDedicatedWorker(body);
      const response = new Response(JSON.stringify(result), {
        headers: { 'Content-Type': 'application/json' },
      });
      await cache.put(event.request, response.clone());
      return response;
    })()
  );
});

function runInDedicatedWorker(buffer: ArrayBuffer): Promise<unknown> {
  return new Promise((resolve, reject) => {
    const w = new Worker(new URL('./csv-parser.worker.js', self.location.href));
    w.onmessage = (e) => { w.terminate(); resolve(e.data); };
    w.onerror = (e) => { w.terminate(); reject(new Error(e.message)); };
    w.postMessage({ csv: buffer }, [buffer]);
  });
}

When neither pattern applies alone

For real-time analytics dashboards where the user expects sub-100ms updates on every filter change, neither a service worker cache nor a single dedicated worker is enough. Layer both:

  1. Service worker intercepts the fetch and checks the cache first.
  2. On a miss, the SW delegates to a Worker Pool Management pool (pool lives in the page context, not in the SW) via a MessageChannel port stored in sessionStorage or IndexedDB.
  3. The pool runs the computation, returns the result to the SW via that port, and the SW caches and responds.

This layered model gives you cache-first speed on warm requests and parallel computation on cold requests, without ever blocking the main thread or risking SW termination.

Concrete Performance Numbers

Scenario Dedicated Worker Service Worker (direct)
Parse 500 KB JSON 8–12 ms (dedicated thread) 8–12 ms + delays other SW responses
Parse 10 MB CSV 180–400 ms (safe, unbounded) Risk of SW kill after ~30 s timeout
Return cached 10 MB result Requires postMessage (~0.1 ms transfer) caches.match() ~0.5 ms, then Response stream
Coordinate 4 workers in parallel WorkerPool — standard pattern Not directly possible from SW without BroadcastChannel
Run WebAssembly module Supported Supported, but WASM compile time extends SW lifetime risk

Decision Flowchart

Does the task run a CPU-bound loop > 50ms?
  YES → Use a dedicated worker.
        Need fetch interception or caching?
          YES → Also register a SW; have the SW delegate to the dedicated worker.
          NO  → Dedicated worker alone is sufficient.
  NO  → Is the task triggered by a network request?
          YES → Service worker event handler is fine (keep it under 50ms).
          NO  → Main thread or dedicated worker depending on blocking risk.

Gotchas

Gotcha 1 — SW termination is not predictable across browsers. Chrome gives roughly 30 seconds. Safari on iOS can terminate a SW after 5–10 seconds under memory pressure. Never assume a 30-second budget.

Gotcha 2 — SharedArrayBuffer is unavailable in service worker scope. Even with COOP and COEP headers set on the document, the SW’s global scope is not cross-origin isolated. Attempting new SharedArrayBuffer(...) inside a SW will throw. Use dedicated workers for any Atomics-based coordination.

Gotcha 3 — Spawning a new Worker() per cache miss adds 5–15ms. If your cache miss rate is high (first visit, no precache), this adds up. Measure whether a persistent dedicated worker that the SW communicates with via a stored MessagePort is faster for your traffic pattern.

Gotcha 4 — Module workers in SW scope have limited browser support. new Worker(url, { type: 'module' }) inside a service worker works in Chrome 91+ and Edge 91+, but not in Firefox as of mid-2026. Use classic workers with bundled output when targeting Firefox.

Frequently Asked Questions

Which worker type should I use for a long-running image-processing loop?
Use a dedicated worker. It runs on its own OS thread, is not subject to event-driven termination, and persists as long as you hold a reference to it. A service worker risks being killed by the browser before the loop finishes, and running the loop in a fetch handler blocks all other SW responses.
Can a service worker do any computation at all?
Yes — lightweight, fast operations (under ~50ms) are fine in a service worker event handler, especially when the result will be cached. For anything heavier, the correct pattern is to delegate to a dedicated worker spawned from the SW and await the result.

See also