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
SharedArrayBufferfor 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
installor 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:
- Service worker intercepts the
fetchand checks the cache first. - On a miss, the SW delegates to a Worker Pool Management pool (pool lives in the page context, not in the SW) via a
MessageChannelport stored insessionStorageorIndexedDB. - 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.