The Single-Threaded Problem
JavaScript runs on a single thread. Every time your code runs — parsing JSON, sorting a large array, rendering a chart — it blocks the UI thread. Users see janky animations, unresponsive buttons, and 60fps dropping to 5fps. The solution is simple in principle: move expensive work off the main thread.
The browser gives us two distinct APIs for this:
- Web Workers — dedicated background threads for CPU-intensive computation
- Service Workers — proxy workers that intercept network requests, enable offline-first caching, and power background sync
They share the concept of a message-passing interface but serve entirely different purposes. You can quickly test JSON payloads returned by workers using the DevKits JSON Formatter.
Web Workers: CPU Offloading
A Web Worker runs in a separate OS thread. It has no access to the DOM, window, or document, but it can use fetch, IndexedDB, WebSockets, and most Web APIs. Communication is via structured-clone message passing.
Basic Worker Setup
// main.js
const worker = new Worker("./worker.js");
worker.postMessage({ type: "SORT", payload: largeArray });
worker.onmessage = (event) => {
const { type, result } = event.data;
if (type === "SORT_DONE") {
renderTable(result);
}
};
worker.onerror = (err) => {
console.error("Worker error:", err.message);
};
// worker.js
self.onmessage = (event) => {
const { type, payload } = event.data;
if (type === "SORT") {
// This runs off the main thread — no UI jank
const sorted = [...payload].sort((a, b) => a.value - b.value);
self.postMessage({ type: "SORT_DONE", result: sorted });
}
};
Inline Workers with Blob URLs
Instead of a separate file, you can create workers inline — useful for bundlers or dynamic logic:
const workerCode = `
self.onmessage = ({ data }) => {
const result = data.map(n => n * n);
self.postMessage(result);
};
`;
const blob = new Blob([workerCode], { type: "application/javascript" });
const worker = new Worker(URL.createObjectURL(blob));
worker.postMessage([1, 2, 3, 4, 5]);
worker.onmessage = (e) => console.log(e.data); // [1, 4, 9, 16, 25]
Transferable Objects: Zero-Copy Transfers
By default, postMessage uses structured clone — it copies the data. For large binary data (images, audio, WebAssembly memory), use Transferable objects to transfer ownership instead of copying:
// main.js — transfer an ArrayBuffer to the worker (zero-copy)
const buffer = new ArrayBuffer(1024 * 1024 * 100); // 100 MB
worker.postMessage({ buffer }, [buffer]);
// buffer is now detached in main — zero copy occurred
// worker.js
self.onmessage = ({ data }) => {
const view = new Uint8Array(data.buffer);
// Process the buffer...
// Transfer it back when done
self.postMessage({ buffer: data.buffer }, [data.buffer]);
};
Worker Pool Pattern
Creating a new Worker is expensive (~1ms). For frequent tasks, maintain a pool:
class WorkerPool {
constructor(workerFile, poolSize = navigator.hardwareConcurrency || 4) {
this.workers = Array.from({ length: poolSize }, () => ({
worker: new Worker(workerFile),
busy: false,
}));
this.queue = [];
}
run(data) {
return new Promise((resolve, reject) => {
const idle = this.workers.find(w => !w.busy);
if (idle) {
this._dispatch(idle, data, resolve, reject);
} else {
this.queue.push({ data, resolve, reject });
}
});
}
_dispatch(entry, data, resolve, reject) {
entry.busy = true;
entry.worker.postMessage(data);
entry.worker.onmessage = ({ data: result }) => {
resolve(result);
entry.busy = false;
if (this.queue.length) {
const next = this.queue.shift();
this._dispatch(entry, next.data, next.resolve, next.reject);
}
};
entry.worker.onerror = reject;
}
}
const pool = new WorkerPool("./image-processor.js", 4);
await Promise.all(images.map(img => pool.run(img)));
Service Workers: Network Proxy and Caching
A Service Worker is a special worker that sits between your app and the network. It intercepts every fetch request your page makes and can respond from a cache, modify the request, or fall back to the network. This is the foundation of Progressive Web Apps (PWAs).
Registering a Service Worker
// main.js
if ("serviceWorker" in navigator) {
window.addEventListener("load", async () => {
try {
const reg = await navigator.serviceWorker.register("/sw.js", {
scope: "/",
});
console.log("SW registered:", reg.scope);
} catch (err) {
console.error("SW registration failed:", err);
}
});
}
Cache-First Strategy
The most common pattern: serve from cache if available, otherwise fetch from network and cache the response.
// sw.js
const CACHE_NAME = "devkits-v1";
const PRECACHE_URLS = [
"/",
"/index.html",
"/blog/assets/style.css",
"/blog/assets/main.js",
];
// Install: precache critical assets
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(PRECACHE_URLS))
);
self.skipWaiting();
});
// Activate: clean up old caches
self.addEventListener("activate", (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(
keys
.filter((key) => key !== CACHE_NAME)
.map((key) => caches.delete(key))
)
)
);
self.clients.claim();
});
// Fetch: cache-first, network fallback
self.addEventListener("fetch", (event) => {
// Only cache GET requests
if (event.request.method !== "GET") return;
event.respondWith(
caches.match(event.request).then((cached) => {
if (cached) return cached;
return fetch(event.request).then((response) => {
// Clone because response body can only be read once
const clone = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
return response;
});
})
);
});
Network-First Strategy (for API calls)
// Network first, cache as fallback — ideal for API responses
self.addEventListener("fetch", (event) => {
if (!event.request.url.includes("/api/")) return;
event.respondWith(
fetch(event.request)
.then((response) => {
const clone = response.clone();
caches.open(CACHE_NAME).then((c) => c.put(event.request, clone));
return response;
})
.catch(() => caches.match(event.request))
);
});
Background Sync
Background Sync lets you defer actions until the user has a stable connection:
// main.js — register a sync when offline
async function saveOffline(data) {
const db = await openIndexedDB();
await db.put("pending", data);
if ("serviceWorker" in navigator && "SyncManager" in window) {
const reg = await navigator.serviceWorker.ready;
await reg.sync.register("sync-pending-data");
}
}
// sw.js — process when back online
self.addEventListener("sync", (event) => {
if (event.tag === "sync-pending-data") {
event.waitUntil(flushPendingData());
}
});
async function flushPendingData() {
const pending = await getPendingFromIDB();
for (const item of pending) {
await fetch("/api/submit", { method: "POST", body: JSON.stringify(item) });
await removeFromIDB(item.id);
}
}
Common Pitfalls
Service Worker scope gotcha
A Service Worker only controls pages within its scope. A worker at /blog/sw.js can only intercept requests from /blog/*. Place your sw.js at the root (/sw.js) to control the entire origin.
Stale cache after deploy
If you update your app but forget to bump the cache version string, users keep getting the old cached assets. Always increment CACHE_NAME (e.g., v1 → v2) or use a content hash in the cache key.
Web Worker debugging
Workers appear as separate threads in Chrome DevTools under "Sources > Threads". You can set breakpoints and inspect their scope just like the main thread.
Transferable detachment
After you transfer an ArrayBuffer via postMessage, the buffer in the sender's context becomes detached (zero byteLength). Any access will throw. Keep a reference only on one side at a time.
When to Use Each
| Use Case | Web Worker | Service Worker |
|---|---|---|
| Heavy computation (sort, compress, parse) | Yes | No |
| Offline caching / PWA | No | Yes |
| Background sync | No | Yes |
| WebAssembly execution | Yes | Possible |
| Push notifications | No | Yes |
Summary
- Web Workers unblock the main thread for CPU-intensive tasks — use a pool to avoid creation overhead
- Transferable objects give zero-copy performance for large binary data
- Service Workers act as a network proxy — implement cache-first for static assets and network-first for APIs
- Place
sw.jsat the root, bump cache version on every deploy, and always clone responses before caching - Background Sync turns your app into a resilient offline-first experience