Service Workers for Computation
Part of the High-Performance Computation Patterns guide, this page covers the realistic role a service worker can play in a computation pipeline — and where that role ends.
Service workers are event-driven network proxies. They sit between your page and the network, intercept fetch events, and return Response objects from cache or from a live network call. This event-driven design makes them powerful for caching, background sync, and request routing. It also makes them a poor choice for sustained CPU-bound computation. A service worker that spends 500ms computing a Fourier transform inside a fetch handler delays every other response it is supposed to serve during that time — and the browser may kill the worker before the computation finishes.
The pattern that works is the opposite: service workers coordinate computation by delegating heavy loops to a dedicated worker, then caching the result so the next request returns in under a millisecond.
Prerequisites
You should already be comfortable with: registering a service worker, the install / activate / fetch event sequence, and the basics of new Worker(). If dedicated workers are new to you, read the High-Performance Computation Patterns overview first.
- HTTPS (or
localhost) — service workers require a secure origin. - A modern browser — Chrome 40+, Firefox 44+, Safari 11.1+, Edge 17+.
- A build step that emits a cacheable URL for your compute worker script (or use an inline worker — see Step 3).
- The Worker Pool Management pattern if more than one computation can be in-flight simultaneously.
Step 1 — Understand Why Service Workers Die Between Events
A service worker is not a long-running daemon. The browser launches it on demand — when a controlled page fires a fetch, the SW receives an event. Once the event handler’s extended lifetime promise (event.waitUntil(...)) settles, the browser considers the SW idle. After a browser-specific timeout (roughly 30 seconds in Chrome, shorter under memory pressure), the browser terminates the process.
This is by design: multiple open tabs share a single SW instance for an origin, and keeping it alive wastes memory for every user who left the tab in the background.
The practical consequence: any computation started inside a fetch handler must complete before the handler’s respondWith promise resolves. If you kick off a 2-second FFT inside respondWith, the browser will wait those 2 seconds before returning the response. During those 2 seconds, no other fetch event on the same origin can be processed by this SW — they queue behind your computation. On low-end devices, the SW can be killed before the 2 seconds are up.
// sw.ts — ANTIPATTERN: synchronous CPU work in a fetch handler
self.addEventListener('fetch', (event: FetchEvent) => {
if (event.request.url.includes('/compute')) {
event.respondWith(
(async () => {
// BAD: this blocks the SW event loop for ~800ms on a mid-range phone
const result = expensiveFFT(await event.request.arrayBuffer());
return new Response(result);
})()
);
}
});
Running CPU work directly in a service worker event handler serializes all SW responses — every concurrent fetch to any URL on the origin queues behind your computation. A dedicated worker runs on a separate OS thread and is not subject to the SW's event-driven termination, so it can loop for minutes without affecting any other fetch or sync event.
Step 2 — Where Service Workers Genuinely Help: Cache-First Computation
The correct model flips the relationship: the SW is the cache layer and coordination point; the actual CPU work lives elsewhere. On a cache hit, the SW returns a stored Response in under 1ms — no computation at all.
// sw.ts — GOOD: cache-first with fallback to dedicated worker
const CACHE_NAME = 'computed-v1';
self.addEventListener('fetch', (event: FetchEvent) => {
if (!event.request.url.includes('/api/compute')) return;
event.respondWith(
(async () => {
const cache = await caches.open(CACHE_NAME);
const cached = await cache.match(event.request);
if (cached) return cached; // sub-millisecond return
// Cache miss: delegate to a dedicated worker (see Step 3)
const result = await delegateToWorker(event.request);
const response = new Response(JSON.stringify(result), {
headers: { 'Content-Type': 'application/json', 'X-Computed-By': 'worker' },
});
await cache.put(event.request, response.clone());
return response;
})()
);
});
A caches.match() round-trip completes in 0.3–1ms (measured via performance.now() in Chrome 124). A cold computation that previously took 200ms on the main thread returns identically fast on every subsequent request, regardless of device CPU speed. Design your cache key to be stable for the same logical input.
Step 3 — Spawning a Dedicated Worker from the Service Worker
When a computation cache miss occurs, the SW creates a Worker, passes the computation parameters, and waits for the result. The dedicated worker runs on a separate OS thread; the browser cannot terminate it mid-computation due to idle timeout pressure.
// sw.ts — helper: spawn a dedicated worker and await one result
function delegateToWorker(request: Request): Promise<unknown> {
return new Promise(async (resolve, reject) => {
// Use a URL relative to the SW script location
const worker = new Worker(new URL('./compute-worker.js', self.location.href));
const timeout = setTimeout(() => {
worker.terminate();
reject(new Error('Compute worker timed out after 10 s'));
}, 10_000);
worker.onmessage = (e: MessageEvent) => {
clearTimeout(timeout);
worker.terminate(); // clean up after single use
resolve(e.data);
};
worker.onerror = (e: ErrorEvent) => {
clearTimeout(timeout);
worker.terminate();
reject(new Error(e.message));
};
// Clone the request body to pass as ArrayBuffer
const body = await request.arrayBuffer();
worker.postMessage({ url: request.url, body }, [body]); // zero-copy transfer
});
}
// compute-worker.ts — runs on its own thread, safe for long loops
self.onmessage = (e: MessageEvent<{ url: string; body: ArrayBuffer }>) => {
const { body } = e.data;
const input = new Float64Array(body);
// This loop can run for seconds without any termination risk
const result = performHeavyTransform(input);
const output = result.buffer as ArrayBuffer;
self.postMessage(output, [output]); // transfer back, zero-copy
};
function performHeavyTransform(data: Float64Array): Float64Array {
// Realistic example: running sum normalization over 1M samples
let sum = 0;
for (let i = 0; i < data.length; i++) sum += data[i];
const mean = sum / data.length;
const out = new Float64Array(data.length);
for (let i = 0; i < data.length; i++) out[i] = data[i] - mean;
return out;
}
Spawning a fresh Worker for each cache miss costs 5–15ms for initialization. Under concurrent request bursts, you create N workers simultaneously. For production use, consider a shared dedicated-worker pool managed outside the SW using a Worker Pool Management approach, and communicate with it via BroadcastChannel or a MessageChannel port stored in IndexedDB.
Step 4 — Background Sync for Pre-Warming the Computation Cache
The Background Sync API lets you schedule a sync event that fires when the device next has network access (or immediately if it already does). This is useful for pre-computing and caching results while the user is not actively waiting.
// main.ts — register a background sync from the page
async function schedulePrewarm(params: ComputeParams) {
const reg = await navigator.serviceWorker.ready;
// Store params in IndexedDB so the SW can read them on sync
await storeParamsForSync('prewarm-compute', params);
await reg.sync.register('prewarm-compute');
}
// sw.ts — handle the sync event
self.addEventListener('sync', (event: SyncEvent) => {
if (event.tag === 'prewarm-compute') {
event.waitUntil(
(async () => {
const params = await loadParamsForSync('prewarm-compute');
const syntheticRequest = new Request(
`/api/compute?key=${params.key}&version=${params.version}`
);
const cache = await caches.open(CACHE_NAME);
const cached = await cache.match(syntheticRequest);
if (cached) return; // already warm
const result = await delegateToWorker(syntheticRequest);
await cache.put(
syntheticRequest,
new Response(JSON.stringify(result), {
headers: { 'Content-Type': 'application/json' },
})
);
})()
);
}
});
Background Sync (one-shot) is supported in Chrome and Edge. Firefox and Safari do not support it as of mid-2026. Use Periodic Background Sync only for non-critical prewarms and always provide a synchronous fallback path via fetch.
Background sync is best-effort: it may fire minutes after scheduling. Never use it as a substitute for a user-initiated computation. Use it exclusively to pre-warm caches for results the user is likely to request next, making their next interaction instant.
Step 5 — Intercepting Precomputed Responses
A service worker can also serve entirely precomputed, build-time-generated responses from a static cache populated during the install event. This is a zero-latency pattern for expensive results that do not change at runtime (e.g., compiled WASM modules, reference datasets, colour look-up tables).
// sw.ts — install event: pre-cache build-time computed assets
const PRECACHE_URLS = [
'/assets/lookup-table.bin',
'/assets/reference-vectors.bin',
];
self.addEventListener('install', (event: ExtendableEvent) => {
event.waitUntil(
(async () => {
const cache = await caches.open(CACHE_NAME);
await cache.addAll(PRECACHE_URLS);
// Force the new SW to activate immediately rather than waiting
await (self as ServiceWorkerGlobalScope).skipWaiting();
})()
);
});
self.addEventListener('activate', (event: ExtendableEvent) => {
event.waitUntil(
(async () => {
// Evict stale cache versions
const keys = await caches.keys();
await Promise.all(
keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k))
);
await (self as ServiceWorkerGlobalScope).clients.claim();
})()
);
});
A 2MB lookup table fetched from the Cache API completes in under 5ms in all modern browsers. The same table computed at runtime (e.g., a gamma-correction LUT across 16M colour values) takes 200–800ms on a typical mid-range phone. Precache everything that is known at build time.
Data Transfer Strategy
Different payloads need different transfer strategies across the SW–dedicated-worker boundary.
| Payload type | Size range | Strategy | Notes |
|---|---|---|---|
| JSON parameters | < 50 KB | Structured clone (default) | postMessage(obj) — no transfer list needed |
| ArrayBuffer (audio, image) | 1 MB – 100 MB | Transfer via transfer list | postMessage(buf, [buf]) — zero-copy, sub-ms |
| SharedArrayBuffer | Any | Not usable from SW | SWs cannot access SharedArrayBuffer; COOP/COEP headers are not applicable to SW scope |
| Response body stream | Any | response.arrayBuffer() then transfer |
Read the stream first, then transfer the buffer |
| Typed arrays | Any | Transfer the underlying .buffer |
postMessage(arr.buffer, [arr.buffer]) — arr is detached after |
The key rule: anything larger than 50 KB should be transferred as an ArrayBuffer rather than structured-cloned. Structured cloning a 4 MB buffer in Chrome 124 takes roughly 3–6ms; transferring it takes under 0.1ms.
For the Data Parsing & Serialization pattern, the SW acts as the cache layer: the first parse of a large JSON payload is delegated to a dedicated worker and the result stored; all subsequent requests return the cached, already-parsed structure without touching the CPU.
Verification & Measurement
Measure cache hit rate with the Performance API
// sw.ts — instrument cache hits vs. misses
self.addEventListener('fetch', (event: FetchEvent) => {
if (!event.request.url.includes('/api/compute')) return;
event.respondWith(
(async () => {
const t0 = performance.now();
const cache = await caches.open(CACHE_NAME);
const cached = await cache.match(event.request);
if (cached) {
const duration = performance.now() - t0;
console.log(`[SW] Cache hit in ${duration.toFixed(2)} ms`);
return cached;
}
console.log('[SW] Cache miss — delegating to worker');
const result = await delegateToWorker(event.request);
const duration = performance.now() - t0;
console.log(`[SW] Compute + cache write in ${duration.toFixed(2)} ms`);
const response = new Response(JSON.stringify(result), {
headers: { 'Content-Type': 'application/json' },
});
await cache.put(event.request, response.clone());
return response;
})()
);
});
DevTools checklist
- Application panel → Service Workers: confirm the SW is active and not waiting.
- Application panel → Cache Storage: verify
computed-v1contains the expected keys after the first miss. - Performance panel → record a fetch: the main thread should show no long tasks. The dedicated worker’s CPU usage appears in the “Workers” track at the bottom of the flame chart.
- Network panel: a cache hit shows
(ServiceWorker)in the Size column and a duration of under 2ms. - Console: check the
[SW]log lines to confirm hit vs. miss ratios match expectations.
Failure Modes & Error Handling
The service worker is killed mid-computation
If a computation miss triggers a dedicated worker that takes 15 seconds, the SW itself may be killed before the dedicated worker posts back its result. The fetch event’s respondWith promise will reject, and the page receives a network error.
Mitigations:
- Keep dedicated worker computation under 10 seconds per task. Larger jobs should be split into chunks (see Data Parsing & Serialization).
- Use
event.waitUntil()in addition torespondWith()to keep the SW alive while the worker computes:
self.addEventListener('fetch', (event: FetchEvent) => {
const computePromise = /* ... */ Promise.resolve(null);
event.waitUntil(computePromise); // extends SW lifetime
event.respondWith(computePromise.then(result => new Response(JSON.stringify(result))));
});
Cache key collisions
Using the raw request.url as the cache key conflates requests with different headers (e.g., Accept-Encoding). Generate a deterministic synthetic key from the logical parameters:
function buildCacheKey(params: ComputeParams): Request {
const key = `/api/compute?${new URLSearchParams(params as Record<string, string>).toString()}`;
return new Request(key, { method: 'GET' });
}
Dedicated worker crashes
If the dedicated worker throws an unhandled error, the onerror handler in the SW must reject the computation promise so the SW returns a meaningful error response rather than hanging:
worker.onerror = (e: ErrorEvent) => {
worker.terminate();
reject(new Error(`Compute worker error: ${e.message}`));
};
Browser Compatibility
| Feature | Chrome | Firefox | Safari | Edge |
|---|---|---|---|---|
| Service Worker (basic) | 40 | 44 | 11.1 | 17 |
CacheStorage / Cache API |
40 | 39 | 11.1 | 17 |
new Worker() from SW scope |
40 | 44 | 13.1 | 17 |
| Background Sync (one-shot) | 49 | Not supported | Not supported | 79 |
| Periodic Background Sync | 80 | Not supported | Not supported | 80 |
skipWaiting() |
41 | 44 | 11.1 | 17 |
| SW update on page reload | 45 | 44 | 12 | 17 |
Safari’s service worker support has historically lagged, with notable gaps in background sync and module workers in SW scope. Test in Safari Technology Preview when targeting iOS PWAs. Firefox lacks Background Sync but fully supports SW-spawned dedicated workers.
For a detailed side-by-side analysis of which worker type to use for a given CPU task, see Dedicated vs Service Workers for CPU Tasks.