Workers everywhere: which "worker" do you mean?
Web Worker, Service Worker, worker_threads, job worker, Cloudflare Worker: what does each one actually run, and when do I need which?
"Worker" names at least eight different things that share one idea: run code somewhere other than the thread or process handling the user's request. They differ in where they run, how long they live, what they can touch, and how you talk to them.
THE MENTAL MODEL
Sort every worker by two questions: what is it isolated from, and who decides its lifetime? Browser workers are extra JavaScript threads with no DOM that you talk to by message passing: a dedicated worker belongs to one page, a SharedWorker to every same-origin tab that connects, and a Service Worker belongs to the origin itself, sits between the page and the network, and is started and killed by the browser around events. Worklets are tiny hooks the browser calls from its own audio or rendering threads. On servers, worker_threads and cluster are the same parallelism idea applied to a Node process (threads vs processes), job workers are separate processes that pull durable tasks from a queue so the request path can return early, and edge "Workers" are not background anything: they are request handlers running in V8 isolates close to the user. Pick by the problem: blocked main thread, multiple tabs, offline and network control, CPU-bound server work, deferred and retryable work, or latency to the user.
HOW IT FITS TOGETHER
| Runs where | Lifetime | DOM access | Shared state | Typical use | |
|---|---|---|---|---|---|
| Dedicated Web Worker | Background thread of one page | Until page closes or terminate() | No | Only via SharedArrayBuffer; otherwise postMessage copies | Parsing, crypto, image or data crunching off the main thread |
| SharedWorker | One thread shared by same-origin tabs | While at least one tab holds a port | No | Its own globals, seen by every connected tab | One socket or cache shared by all open tabs |
| Service Worker | Browser, per origin and scope, outside any page | Event-driven; killed when idle, restarted on events | No | None in memory you can rely on; use Cache API / IndexedDB | Offline, fetch interception, push, background sync |
| Worklet (audio, paint) | Browser audio or rendering thread | Owned by AudioContext or CSS engine | No | None; tightly restricted globals | Custom AudioWorkletProcessor, CSS paint() |
| Node worker_threads | Thread inside one Node process | Until it exits or terminate() | n/a | SharedArrayBuffer; else cloned messages | CPU-bound work without blocking the event loop |
| Node cluster / PM2 processes | Separate OS processes on one host | As long as the process manager keeps them | n/a | None; use Redis or a DB | Use every core for an HTTP server |
| Job worker (BullMQ, Sidekiq) | Separate processes or containers | Long-running; pulls jobs from a broker | n/a | None; job payload plus your DB | Email, webhooks, reports, retries outside the request |
| Cloudflare Worker (edge) | V8 isolate in a CDN location | Per request; isolate may be reused or evicted | No (HTMLRewriter streams HTML) | Globals not guaranteed between requests | Edge routing, auth, caching, APIs near users |
- Page → Browserregister('/sw.js', { scope: '/' })Requires a secure context. Max scope is the script's directory unless Service-Worker-Allowed widens it.
- Browser → Cache API + networkFetch sw.js, byte-compare to currentAlso rechecked after navigations and functional events, at least every 24 hours. Any byte difference starts a new install.
- Browser → Service worker v2install eventevent.waitUntil(promise): if it rejects, this version is thrown away and the old one stays.
- Service worker v2 → Cache API + networkcache.addAll(precache list)
- Cache API + network → Service worker v2Precache stored
- Browser → Service worker v2waiting: v1 still controls open clientsOnly one version runs at a time. Stays here until every v1 tab closes, or self.skipWaiting() is called.
- Browser → Service worker v2activate eventDelete old caches here. Fetch, push and sync events are buffered until waitUntil settles.
- Service worker v2 → PageControls page (reload or clients.claim())Pages opened before registration are uncontrolled until reload unless the worker calls clients.claim().
- Page → Service worker v2fetch event for each in-scope request
- Service worker v2 → Cache API + networkcaches.match(), else fetch()
- Cache API + network → Service worker v2Cached or network Response
- Service worker v2 → Pageevent.respondWith(response)
- Browser → Service worker v2Idle: terminated, restarted on next eventGlobal variables do not survive. Anything that must persist goes in IndexedDB or the Cache API.
KEY TERMS
- Dedicated Web Worker
- new Worker(url, { type: "module" }) starts a thread owned by one document. It has fetch, timers, IndexedDB and WebSocket but no DOM, window or localStorage. Data crosses by postMessage (structured clone or transfer); the main thread stays responsive while it computes.
- SharedWorker
- One instance per origin + script URL + name, reached from each tab through a MessagePort delivered in the connect event. Useful for deduplicating a socket or cache across tabs. MDN marks it Baseline 2026: Chrome on Android had it disabled for years and re-enabled it in Chrome 148. Ports give no close signal, so tabs must say goodbye themselves.
- Service Worker
- An event-driven proxy registered per origin and scope. It intercepts navigations and subresource requests in scope (fetch event), receives push and sync events with no tab open, and is terminated when idle. Its lifecycle (install, waiting, activate) guarantees only one version controls pages at a time, which is why deploys can appear stuck.
- Worklets
- Lightweight, purpose-specific scripts the browser calls from its own pipeline: AudioWorklet calls process() on the audio render thread once per render quantum (currently 128 frames); the CSS Paint API (paint worklets) is still experimental and not Baseline. They cannot be used for general computation and allow only static imports.
- Threads vs processes on the server
- worker_threads gives a Node process more JavaScript threads, each with its own isolate and event loop, sharing memory only through SharedArrayBuffer. cluster or PM2 forks whole processes that share a listening port but no memory. Node's docs: threads help CPU-intensive work, not I/O.
- Job workers
- BullMQ (Redis), Sidekiq (Ruby, Redis), Celery (Python, a broker) and Temporal workers (which poll task queues and run durable workflow code) are long-running processes that pull work from a durable store. Delivery is at least once, so handlers must be idempotent; the queue gives retries, backoff and visibility the request path does not.
- Edge Workers
- Cloudflare Workers run your fetch handler in V8 isolates inside an existing runtime, so there is no per-script process to cold start. Each isolate gets up to 128 MB; CPU time is limited per request (10 ms on Free, 30 s default on Paid). Isolates may be evicted at any time, so globals are a cache at best.
IN YOUR STACK
JavaScript · Web Worker (module) Client
// main.js: promise wrapper with request ids
const worker = new Worker(new URL("./parse.worker.js", import.meta.url), { type: "module" });
const pending = new Map();
let nextId = 0;
worker.onmessage = ({ data }) => {
pending.get(data.id)?.(data.rows);
pending.delete(data.id);
};
worker.onerror = (event) => console.error("worker failed:", event.message);
export function parseCsvInWorker(csvText) {
const id = nextId++;
return new Promise((resolve) => {
pending.set(id, resolve);
worker.postMessage({ id, csvText }); // structured clone: the string is copied
});
}
// parse.worker.js: no DOM here, just compute and post back
self.onmessage = ({ data }) => {
const rows = data.csvText.split("\n").map((line) => line.split(","));
self.postMessage({ id: data.id, rows });
};- Bundlers (Vite, webpack 5) recognise new Worker(new URL(..., import.meta.url)) and emit the worker as its own chunk.
- Every message is copied with the structured clone algorithm. For large binary data, transfer the ArrayBuffer instead (see the multithreaded-javascript explainer).
- Comlink wraps exactly this id-and-promise plumbing if you prefer RPC-style calls.
JavaScript · Service Worker + Cache API Client
// sw.js (page: navigator.serviceWorker.register("/sw.js"))
const CACHE = "static-v3";
const PRECACHE = ["/", "/app.js", "/styles.css", "/offline.html"];
const dropOld = (keys) => Promise.all(keys.filter((key) => key !== CACHE).map((key) => caches.delete(key)));
self.addEventListener("install", (event) => event.waitUntil(caches.open(CACHE).then((c) => c.addAll(PRECACHE))));
self.addEventListener("activate", (event) => event.waitUntil(caches.keys().then(dropOld)));
self.addEventListener("fetch", (event) => {
const { request } = event;
if (request.method !== "GET" || new URL(request.url).origin !== location.origin) return;
event.respondWith((async () => {
const cached = await caches.match(request);
if (cached) return cached;
try {
const response = await fetch(request);
const copy = response.clone(); // clone now, before the page consumes the body
if (response.ok) event.waitUntil(caches.open(CACHE).then((c) => c.put(request, copy)));
return response;
} catch {
return (request.mode === "navigate" && (await caches.match("/offline.html"))) || Response.error();
}
})());
});- Cache-first suits versioned static assets. For HTML and API data use network-first or stale-while-revalidate, or users keep seeing the old app until the cache name changes.
- Returning without calling respondWith lets the browser handle the request normally; here that covers non-GET and cross-origin requests.
- A Response body can be read once, so clone it before putting one copy in the cache.
JavaScript · Node.js worker_threads Server
import { Worker, isMainThread, parentPort, workerData } from "node:worker_threads";
import { createHash } from "node:crypto";
if (isMainThread) {
const hashInWorker = (input) => new Promise((resolve, reject) => {
const worker = new Worker(new URL(import.meta.url), { workerData: input });
worker.once("message", resolve);
worker.once("error", reject);
worker.once("exit", (code) => { if (code !== 0) reject(new Error(`worker exited with ${code}`)); });
});
// The event loop keeps serving requests while this runs on another core.
console.log(await hashInWorker("seed"));
} else {
let digest = workerData;
for (let i = 0; i < 1_000_000; i++) digest = createHash("sha256").update(digest).digest("hex");
parentPort.postMessage(digest);
}- Each worker has its own V8 isolate and event loop; starting one costs milliseconds and memory, so use a pool (Piscina) for per-request work.
- Node's docs are explicit: workers help CPU-intensive JavaScript, not I/O, which the main event loop already handles efficiently.
TypeScript · BullMQ 5 (Redis) Server
import { Queue, Worker, type Job } from "bullmq";
const connection = { host: "127.0.0.1", port: 6379, maxRetriesPerRequest: null };
// Producer, in the API process: enqueue and return immediately.
export const emails = new Queue("emails", { connection });
export const enqueueWelcome = (userId: string) => emails.add("welcome", { userId }, {
jobId: `welcome-${userId}`, // ignored while a job with this id still exists
attempts: 5,
backoff: { type: "exponential", delay: 1_000 },
removeOnComplete: 1_000,
});
// Consumer, in a separate worker process or container.
const worker = new Worker("emails", async (job: Job<{ userId: string }>) => {
await sendWelcomeEmail(job.data.userId); // may run twice: make it idempotent
}, { connection, concurrency: 10 });
worker.on("failed", (job, err) => console.error("job failed", job?.id, err.message));
worker.on("error", (err) => console.error("worker error", err));
process.on("SIGTERM", async () => { await worker.close(); process.exit(0); });
async function sendWelcomeEmail(userId: string) {
console.log("send email to", userId); // pass an idempotency key to the provider
}- Workers hold a lock per job; a CPU-bound processor that starves the event loop cannot renew it and the job is marked stalled and retried. Use sandboxed processors for CPU-heavy work.
- Custom job ids must not be all digits or contain ":". Deduplication by id only lasts until the job is removed (removeOnComplete/removeOnFail).
- Always attach an error listener; worker.close() waits for active jobs to finish, which is what you want on deploy.
TypeScript · Cloudflare Workers Server
interface Env {
PAGES: KVNamespace; // binding declared in wrangler config
ORIGIN: string; // e.g. "https://origin.example.com"
}
export default {
async fetch(request, env, ctx): Promise<Response> {
const url = new URL(request.url);
if (request.method !== "GET") return fetch(new URL(url.pathname + url.search, env.ORIGIN), request);
const key = `page:${url.pathname}`;
const cached = await env.PAGES.get(key);
if (cached !== null) return new Response(cached, { headers: { "content-type": "text/html", "x-cache": "hit" } });
const upstream = await fetch(new URL(url.pathname, env.ORIGIN));
if (!upstream.ok) return upstream;
const html = await upstream.text();
ctx.waitUntil(env.PAGES.put(key, html, { expirationTtl: 300 })); // runs after the response
return new Response(html, { headers: { "content-type": "text/html", "x-cache": "miss" } });
},
} satisfies ExportedHandler<Env>;- Do not keep request state in module globals: a request may land on a fresh isolate, and isolates are evicted under resource pressure.
- ctx.waitUntil extends the invocation after the response is returned (up to 30 s) for logging or cache writes.
- Types come from @cloudflare/workers-types or from running wrangler types, which also generates the Env interface from your bindings.
WHEN TO REACH FOR WHAT
Dedicated Web Worker
Use when One page has CPU-heavy work (parsing, diffing, crypto, image processing) that causes jank or long tasks.
Avoid when The work is mostly I/O (fetch already does not block) or needs the DOM, or the payload copy costs more than the computation.
SharedWorker
Use when Several tabs of the same app should share one WebSocket, one poller or one in-memory cache.
Avoid when You need it to run with no tab open (that is a Service Worker), or you cannot ship a BroadcastChannel fallback for older mobile browsers.
Service Worker
Use when You need offline support, control over caching of requests, push notifications or background sync.
Avoid when You want a long-lived background process or in-memory state; it is killed when idle. Also avoid if nobody owns cache invalidation on deploy.
Worklet
Use when Custom audio DSP on the audio render thread, or custom CSS painting where limited, non-Baseline support is acceptable.
Avoid when General-purpose computation; use a Web Worker.
Node worker_threads (pool)
Use when CPU-bound work (image resize, hashing, parsing large files) inside a Node service that must keep serving requests.
Avoid when The bottleneck is I/O or database latency; threads add cost without speedup.
cluster / multiple processes
Use when One Node HTTP server should use every core on a VM and you are not already running one process per container.
Avoid when An orchestrator (Kubernetes, ECS) already scales replicas; one process per container is simpler to observe and restart.
Background job worker
Use when Work can finish after the response: emails, webhooks, exports, retries with backoff, scheduled tasks.
Avoid when The user must see the result in the same request, or the extra broker is more infrastructure than the problem justifies.
Edge worker (Cloudflare)
Use when Request-time logic should run near users: routing, auth checks, redirects, A/B, caching, light APIs.
Avoid when Long CPU-heavy jobs, Node-only native modules, or chatty access to a single-region database far from the edge.
WHERE IT BITES
- Deploying a new service worker and wondering why users still run the old app: the new version is waiting until every old tab closes. Prompt for reload and call skipWaiting deliberately rather than by default.
- Cache-first on HTML or API responses. The page is pinned to whatever was cached; reserve cache-first for hashed static assets.
- Posting huge objects to a worker on every frame. Structured clone runs on the sending thread too; transfer buffers or keep the data in the worker.
- Running CPU-heavy code in a BullMQ processor in-process: the lock is not renewed, the job stalls and runs twice. Use sandboxed processors or offload to threads.
- Treating an edge isolate's globals or a service worker's globals as durable state. Both are discarded without warning.
- Spawning a new worker_thread per request. Startup cost and memory dominate; use a bounded pool.
CLOSE THE AI. EXPLAIN THIS.
For each of these, name the worker you would use and why: a CSV import freezes the page; three open tabs each hold the same WebSocket; the app must open offline; a Node API resizes uploaded images; a signup should trigger an email with retries; redirects should run in under 10 ms worldwide. Then name what each one cannot access.WHEN IT BREAKS IN PRODUCTION
SOURCES
- web.dev: The service worker lifecycle (opens in new tab)Checked
- MDN: Using Service Workers (opens in new tab)Checked
- MDN: SharedWorker (opens in new tab)Checked
- Chrome 148 release notes (SharedWorker on Android) (opens in new tab)Checked
- Node.js: Worker threads (opens in new tab)Checked
- Cloudflare: How Workers works (opens in new tab)Checked
Explainer reviewed