Skip to content

Worker Patterns

Workers are asynchronous and may handle many tasks. To match a response to the right request, attach a unique id to each message:

main.js
let nextId = 0;
const pending = {};
worker.onmessage = (e) => {
const { id, result } = e.data;
if (pending[id]) { pending[id](result); delete pending[id]; }
};
function ask(payload) {
return new Promise((resolve) => {
const id = ++nextId;
pending[id] = resolve;
worker.postMessage({ id, payload });
});
}
// worker.js
self.onmessage = (e) => {
const { id, payload } = e.data;
self.postMessage({ id, result: process(payload) });
};

Wrapping the pattern above into a small helper makes calling the worker feel like a regular async function call.

function createRpc(worker) {
let seq = 0;
const map = {};
worker.onmessage = (e) => {
const cb = map[e.data.id];
if (cb) { cb(e.data.result); delete map[e.data.id]; }
};
return {
call(method, args) {
return new Promise((res) => {
const id = ++seq;
map[id] = res;
worker.postMessage({ id, method, args });
});
}
};
}

Uncaught errors inside a worker fire worker.onerror in the main thread — not window.onerror. Always attach a handler:

worker.onerror = (event) => {
console.error('Worker error:', event.message, 'at', event.filename, ':', event.lineno);
event.preventDefault(); // suppress browser console error
};

You can also attach self.onerror inside the worker itself to catch async errors before they bubble up to the main thread.

Use a workerDo NOT use a worker
CPU-intensive loops (crypto, image processing, compression)Simple DOM reads/writes
Parsing large JSON or binary dataShort tasks < 5 ms
Real-time data processing (audio, video)Tasks needing direct DOM access
Keeping the UI at 60 fps during heavy workOne-off simple async fetches (use fetch directly)

Runnable: request/response with message IDs

Section titled “Runnable: request/response with message IDs”
Browser Storage
OptionBenefitCost
RPC wrapper (message ID + Promise)Call the worker like a normal async function, and handle several concurrent requests systematicallyMore boilerplate to write (an id counter, a pending map) and an extra abstraction layer that’s slightly harder to debug
Raw postMessage/onmessageSimple, the flow is direct and visible — fine for a worker with a simple message patternWith several requests in flight at once, you must match responses by hand, risking a race condition
Attaching a worker.onerror handlerCatches uncaught errors, preventing a silent failure in productionNeeds a handler on every worker, and you must design it so an error doesn’t leave a pending promise hanging forever
  • Not attaching worker.onerror — an uncaught error inside a worker never shows up in window.onerror. Without a handler, a crash fails silently and is very hard to detect in production.
  • Letting a pending/map grow without ever removing entries — if a request never gets a response (e.g. the worker crashes), its entry sits in the map forever, becoming a memory leak. Add a timeout or a cleanup path for worker errors.
  • Using a worker for work that isn’t worth the overhead — like reading a button click or a single simple fetch. The cost of setting up an RPC wrapper and cross-thread communication is far higher than the benefit for such small tasks.
Why include a unique `id` in each worker message?
Where does an uncaught error inside a Web Worker fire?
Which task is a GOOD use case for a Web Worker?
In the tiny RPC pattern, what does the `map` object store?