A dedicated Web Worker replies through a single message event on the page, so every reply lands in the same handler. How would you build a promise-based request/response API on top of postMessage so that several concurrent callers each get their own answer?
answer
- one handler sees every reply
- the platform correlates nothing for you
- tag it going out, echo it coming back
- a table of outstanding calls
- every path must clear the entry
basics
~20 sAttach a unique id to every outgoing message and keep a map from id to that request's promise resolvers. The worker echoes the id back in its reply; the single message handler looks up the id, settles that promise, and deletes the entry.
solid answer
~50 s`postMessage` is fire-and-forget: there is no reply channel, no ordering guarantee between the work items, and one handler sees every message the worker sends. So you build correlation yourself. Each call gets a monotonically increasing `id`; you store `{ resolve, reject }` for that id in a `Map` and post `{ id, method, params }`. The worker does its work and posts back `{ id, ok, result }` or `{ id, ok: false, error }`, echoing the id unchanged. The page's single `message` handler reads `event.data.id`, pulls the entry out of the map, deletes it, and settles the promise. Three things make it production-grade rather than a demo: a per-request timeout that rejects and removes the entry so the map cannot leak; rejecting *every* pending entry when the worker fires an `error` event or you terminate it, so callers are never left hanging; and a serializable error shape rather than assuming a thrown object survives the copy.
code
javascript · 33 linesconst worker = new Worker('/calc.worker.js');
const pending = new Map();
let nextId = 0;
worker.addEventListener('message', (event) => {
const { id, ok, result, error } = event.data;
const entry = pending.get(id);
if (!entry) return;
pending.delete(id);
clearTimeout(entry.timer);
if (ok) entry.resolve(result);
else entry.reject(new Error(error.message));
});
worker.addEventListener('error', (event) => {
for (const entry of pending.values()) {
clearTimeout(entry.timer);
entry.reject(new Error(event.message));
}
pending.clear();
});
function call(method, params, timeoutMs = 5000) {
const id = ++nextId;
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
pending.delete(id);
reject(new Error(method + ' timed out'));
}, timeoutMs);
pending.set(id, { resolve, reject, timer });
worker.postMessage({ id, method, params });
});
}go deeper
Know that postMessage has no built-in reply mechanism and that one handler receives every message from the worker. Be able to say that requests need an id so answers can be matched to them.
Walk through the mechanics: a counter for ids, a Map from id to resolvers, the worker echoing the id, and the handler deleting the entry before settling. Explain why out-of-order replies are legal.
Demonstrate the failure paths you have actually hit: timeouts that clean up the map, rejecting all pending work when the worker errors or is terminated, a serializable error contract, and capping in-flight requests so a slow worker cannot be flooded.
Treat the channel as a versioned protocol between two independently deployed bundles. Be ready to argue about method naming and payload evolution, whether calls should be batch-shaped by default, and where this belongs relative to a general RPC abstraction the whole team reuses.
## Why correlation is your problem The worker channel gives you exactly two primitives: post a value, and receive a value. It gives you no request identity, no reply address, and no ordering relationship between a message you sent and a message you receive. If the page posts three jobs and the worker posts three results, the page's single `message` handler sees three events and has, on its own, no way to tell which is which — and because jobs may take different amounts of time, the worker is free to answer out of order. So a request/response API is something you layer on top. The pattern is the same one used by every RPC protocol: put an identifier in the request, require the responder to echo it, and keep a table of outstanding requests on the caller's side. ## The shape of it On the page: ```js const pending = new Map(); let nextId = 0; worker.addEventListener('message', (event) => { const { id, ok, result, error } = event.data; const entry = pending.get(id); if (!entry) return; // late or unknown reply — drop it pending.delete(id); clearTimeout(entry.timer); if (ok) entry.resolve(result); else entry.reject(new Error(error.message)); }); ``` And the sending side wraps a promise around the post, storing the resolvers where the handler can find them. The worker's half is deliberately dumb: read `event.data`, dispatch on `method`, post back an object carrying the **same** `id`. A worker that invents its own ids, or omits the id on the error path, breaks the whole scheme — the error path is exactly where people forget. The id itself only has to be unique among *in-flight* requests on that channel. A counter is fine; there is no distributed system here. What matters more is that the id is a plain value that survives the copy — a number or a string, not an object you expect to be matched by identity, because identity does not cross the thread boundary. ## The parts people leave out **Timeouts.** Without one, a worker that throws in a way you did not anticipate, or gets stuck in a loop, leaves a promise pending forever and its entry in the map forever. Set a timer when you register the entry, clear it when the reply arrives, and on expiry delete the entry and reject. Note the ordering: delete first, then reject, so a late reply finds nothing and is discarded rather than settling an already-settled promise. **Worker-level failure.** A `Worker` object fires an `error` event when the worker script throws at top level and `messageerror` when a message cannot be deserialized. Neither carries a request id, because neither is a reply. When one fires — and when you call `worker.terminate()` — you must walk the whole pending map, reject every entry, and clear it. Otherwise a crashed worker presents to callers as a hang, which is far harder to debug than an error. **Errors that cross the boundary.** Do not assume a thrown value arrives intact on the other side; the safe move is for the worker to catch, reduce the failure to a plain serializable shape such as `{ name, message }`, and post that with `ok: false`. The page reconstructs an `Error` from it. This also keeps the stack trace question honest: the useful stack lives in the worker, so if you want it, log it there or include it as a string field. **Backpressure.** Because each call is just a `postMessage`, nothing stops a page from queueing ten thousand requests at a worker that handles four per second. The queue grows, memory grows, and every reply is late. If callers can outpace the worker, cap concurrency on the page side — a simple counter plus a queue — or make the API batch-shaped so one message carries many items. ## When to reach for ports instead An alternative to ids is to create a fresh `MessageChannel` per request and transfer one of its ports with the request; the reply comes back on that private port, so correlation is structural rather than by lookup. It is elegant, and it is genuinely useful when a request produces a *stream* of replies rather than one, or when a third party needs the reply channel. For plain one-shot calls it is usually more machinery than a counter and a `Map`, and it allocates two objects per call — most codebases stay with ids. ## What an interviewer is listening for The id-plus-map core takes thirty seconds to describe. The signal is in what you add around it: cleanup on every path, rejection of everything pending when the worker dies, a serializable error contract, and an honest answer about what happens under load. Those are the parts that separate a snippet from something you would ship behind a library boundary.
- What happens to callers if the worker script throws at top level, and how do you handle it?The `Worker` object fires an `error` event, but no reply arrives for any in-flight request, so every pending promise hangs. The handler must iterate the pending map, reject each entry with the worker error, and clear the map. The same cleanup belongs in your `terminate()` path, otherwise a crashed or torn-down worker looks to callers exactly like a very slow one.
- Why should the worker post a plain error object rather than the thrown value itself?Because you cannot rely on an arbitrary thrown value surviving the copy across the boundary, and a failure to clone would itself throw inside the worker's error path. Catching and reducing to a serializable shape such as `{ name, message }` makes the contract explicit, keeps the reply symmetric with the success case, and lets you decide deliberately whether to ship a stack string.
- Can the page rely on replies arriving in the order the requests were sent?No. Messages posted from the worker arrive in the order the worker posted them, but nothing forces the worker to finish jobs in the order it received them — an async or chunked handler can easily answer a later request first. That is precisely why correlation by id is required rather than a simple FIFO queue of resolvers.
- How would you stop callers from flooding a slow worker?Cap in-flight requests on the page side: keep a counter and a queue, post only while below the cap, and release on each settle. Alternatively reshape the API so one message carries a batch. Both work because the flood is a page-side choice — the platform will happily queue an unbounded number of messages at a busy worker.
saying these in an interview costs you the question
- Assumes replies arrive in the order requests were sent
- Keeps one global resolver overwritten by each new call
- Never deletes map entries, leaking on timeouts and errors
- Leaves promises pending forever when the worker crashes
- Expects a thrown error object to arrive intact unchanged