Talking to a Worker
postMessage is a one-way, asynchronous, unbounded queue with no return value — every request/response protocol on top of it is one you wrote.
The intent, the obvious build, and why it breaks
Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.
How do the page and a worker actually communicate, and what does that protocol cost me?
A person edits a filter. They expect the table to update with results that match what they just typed — not what they typed two keystrokes ago.
Call worker.postMessage(query) and handle the answer in worker.onmessage. It is basically a function call with extra steps.
It is not a function call: there is no return value, no await, and no relationship whatsoever between a message you sent and a message you receive. The correlation is yours to build.
- It is not a function call: there is no return value, no
await, and no relationship whatsoever between a message you sent and a message you receive. The correlation is yours to build. - Two
onmessagehandlers on one worker both fire for every message. A second feature added to the same worker starts receiving the first feature's replies. - Type on a fast keyboard and you have queued six requests. They are delivered in order, but the worker processes them one at a time and the sixth answer is the only one you wanted — the other five each repaint the table on the way past.
- The queue has no backpressure. Post faster than the worker drains and the messages accumulate in memory with nothing telling you it is happening (Memory Leaks).
- A throw inside the worker's handler does not reject anything. The page waits for a reply that will never come, and without an
errorhandler nothing anywhere says so. - The message does not arrive when you post it. It is queued as a task on the receiving event loop, so it lands behind whatever that thread is already doing (Tasks: The Unit That Cannot Be Interrupted).
What is actually happening
In the browser, not in the framework.
postMessage(value)serialises `value` synchronously on the calling thread using the structured clone algorithm, then queues amessageevent on the receiving thread's task queue (Structured Clone and Transferables).- The receiving thread deserialises when it dispatches the event — so the cost is paid twice, once on each side, and each time it blocks that thread.
- Delivery is ordered per port and never lost. Messages posted before the worker script finishes evaluating are buffered and delivered afterwards. Ordering across two different ports is not guaranteed relative to each other.
- The
messageevent is an ordinary task. It competes with clicks, timers and rendering opportunities on the receiving thread, which is why a message posted into a busy main thread can appear to arrive late (The Rendering Opportunity). MessageChannelgives you a pair ofMessagePorts. Transfer one end to the worker and you have a private, independently-ordered channel — the standard way to give one feature its own pipe without every handler seeing every message.onmessageerrorfires when a message arrives that could not be deserialised. It is a distinct event fromonerror, and almost nobody handles it, which is why "the worker went quiet" is such a common bug report.- A
SharedWorkerspeaks only through ports: each connecting document gets a port via theconnectevent, and the worker must track them itself.
What this makes the browser do
And which of it is avoidable.
- Structured-clone serialisation on the posting thread — proportional to the size and shape of the value, and synchronous (Structured Clone and Transferables).
- Allocating and queueing a task on the receiving event loop, plus the
MessageEventobject itself. - Deserialisation on the receiving thread, allocating a fresh object graph in that realm's heap.
- Garbage collecting both graphs afterwards; a high message rate is also an allocation rate, and allocation rate is what triggers collection pauses.
- What it does not do: any deduplication, coalescing, prioritisation or dropping. Every message you post is delivered, however stale it has become.
What one message actually does
Six things happen between postMessage and your handler running, and four of them can cost you. Reading the hop as a pipeline makes it obvious why a large payload hurts twice, and why a message posted into a busy thread arrives late even though the worker was idle the whole time.
The step people forget is the last one. The reply is a task on the main thread's queue, so it lands behind the long task that was already running. A worker cannot fix latency that the main thread is causing.
- 1Serialise (sender thread)
Structured clone walks the value and produces a serialised form.
fails by Blocking the sender proportionally to payload size; throwing
DataCloneErroron a function, a DOM node or a class instance it cannot represent. - 2Queue
A
messageevent is queued as a task on the receiving thread's task queue.fails by Growing without bound when the producer outruns the consumer — no backpressure exists.
- 3Wait
The receiver finishes whatever task it is running.
fails by A long task on the receiver delaying a message that was ready immediately.
- 4Deserialise (receiver thread)
A fresh object graph is allocated in the receiving realm.
fails by Blocking the receiver, again proportionally to size; firing
messageerrorif it cannot be deserialised. - 5Dispatch
onmessageruns, to completion, as one task.fails by An uncaught throw here rejects nothing on the other side — the requester waits forever.
- 6Correlate
Your code matches this reply to the request that asked for it.
fails by Not existing. If you did not write it, a stale reply renders over a fresh one.
Steps 1 and 4 are why payload size matters. Steps 3 and 6 are why "the worker is slow" is usually not about the worker.
A protocol you only write once
The wrapper below turns the message queue back into something that looks like a function call, without pretending the asynchrony is not there. It is deliberately small: an id counter, a map of pending resolvers, a timeout, and error routing that covers all three ways a worker can go silent.
The important detail is pending.delete(id) in every path — resolve, reject, timeout and terminate. A pending map that only shrinks on success is a leak that grows exactly as fast as your error rate.
1type Pending = { resolve: (v: unknown) => void; reject: (e: Error) => void; timer: number }2 3export function createWorkerClient(url: URL, timeoutMs = 30_000) {4 const worker = new Worker(url, { type: 'module' })5 const pending = new Map<number, Pending>()6 let nextId = 07 8 const settle = (id: number, fn: (p: Pending) => void) => {9 const p = pending.get(id)10 if (!p) return // already timed out, or a stale reply11 clearTimeout(p.timer)12 pending.delete(id) // every path deletes. every one.13 fn(p)14 }15 16 worker.onmessage = (e: MessageEvent<{ id: number; ok: boolean; value?: unknown; error?: string }>) => {17 const { id, ok, value, error } = e.data18 settle(id, (p) => (ok ? p.resolve(value) : p.reject(new Error(error))))19 }20 21 // three separate silences, all of which must reject something22 const failAll = (reason: string) => {23 for (const id of [...pending.keys()]) settle(id, (p) => p.reject(new Error(reason)))24 }25 worker.onerror = (e) => failAll(`worker error: ${e.message}`)26 worker.onmessageerror = () => failAll('worker sent an undeserialisable message')27 28 return {29 call<T>(type: string, payload: unknown, transfer: Transferable[] = []): Promise<T> {30 const id = ++nextId31 return new Promise<T>((resolve, reject) => {32 const timer = self.setTimeout(33 () => settle(id, (p) => p.reject(new Error(`${type} timed out`))),34 timeoutMs,35 )36 pending.set(id, { resolve: resolve as (v: unknown) => void, reject, timer })37 worker.postMessage({ id, type, payload }, transfer)38 })39 },40 dispose() { failAll('worker disposed'); worker.terminate() },41 }42}Three distinct silences — a throw, an undeserialisable message, and a worker that simply never replies — all need to reject something. Handling only onerror covers one of the three.
The four ways this goes wrong in production
Each of these has been shipped by competent teams, and each looks like a different problem than it is. Three of them present as "the worker is slow" and none of them are.
The last row is the one worth internalising: the fix for a stale render is a request id, and the fix for a stuck spinner is clearing the loading state on the discarded reply too. They are the same bug, half-fixed.
| Trigger | Symptom | Cause | Response |
|---|---|---|---|
| Worker handler throws on a malformed row | Spinner never stops; no console error visible to the user | A throw inside a worker rejects nothing on the page and, without onerror, is reported nowhere | Catch inside the worker and reply { ok: false }; also wire onerror, onmessageerror and a timeout (Frontend Error Tracking). |
A message posted per input event | Results lag typing by a word; memory climbs during fast entry | No debounce and no backpressure — every keystroke is a queued job the worker will faithfully complete | Debounce at the source, and have the worker check a cancel flag at loop boundaries. |
| A second feature added to the same worker | Feature A renders feature B's data intermittently | Both features subscribed to the default port and both handlers run for every message | One MessageChannel port per feature, or a strict type discriminator checked before anything else. |
| Slow request finishes after a fast one | Table shows results for a filter the user has already changed | In-order delivery guarantees nothing about relevance | Correlate by request id and drop replies for anything but the current one (Five Components, One Request). |
| Stale reply correctly discarded | Right data, but the spinner and aria-busy never clear | The id filter returned early, before the loading state was reset | Clear the busy state on every terminal reply, including the ones you throw away. |
terminate() used to cancel | Unrelated pending requests hang; the next interaction is noticeably slower | Terminating destroys the realm and every in-flight request in it, and the next call pays startup again | Cooperative cancel messages for normal flow; reserve terminate() for teardown. |
How to build it
Most important first.
- Put a request id on every message and echo it on every reply. Ignore replies whose id is not the one you are currently waiting for. This one habit removes the majority of worker-related UI bugs (Out-of-Order Responses).
- Wrap the whole thing in a promise-returning client so calling code sees
await analyse(rows)and never touchespostMessagedirectly. - Debounce at the source. A worker does not make it acceptable to send a message per keystroke; it just moves where the waste burns (Yielding and Scheduling).
- Give distinct features distinct
MessageChannelports rather than multiplexing everything through the worker's default port and switching ontype. - Send a cancel message and have the worker check for it at loop boundaries. There is no way to interrupt a running JavaScript task from outside — only
terminate(), which kills the realm and everything in flight (Cancellation in Concurrency). - Prefer few large messages to many small ones. Per-message overhead is fixed and real; posting a hundred rows individually is much worse than posting one array of a hundred.
- Handle
onerror,onmessageerrorand a timeout. All three are silent failures otherwise, and a UI that waits forever is worse than one that says it failed.
Keyboard, focus, semantics, announcement
A required field on every lesson in this domain, not a section added when there is room.
- Every message boundary is a moment where the interface can be in an in-between state, and in-between states need announcing. A silent gap between "submitted" and "rendered" is invisible to a sighted user for a fraction of a second and indefinite to a screen-reader user who has no other cue.
- The worker cannot announce. Announcement is a DOM operation, so status text must arrive as a message and be written into a live region on the main thread (Live Regions and Announcement).
- Coalesce status messages before announcing. If the worker posts progress thirty times, announce a handful — an assertive region updated at message rate interrupts the user continuously and is unusable.
- When a stale reply is discarded by the id check, make sure the busy state is not discarded with it. Leaving
aria-busy="true"set on a region that has stopped updating tells assistive technology to keep waiting for something that will never come. - Failure needs a route to the user too.
onerrorshould produce visible, focusable error text — not just aconsole.errorthat only a developer will see (Errors People Can Actually Perceive).
What can go wrong
- A pending request whose reply never arrives, because the worker threw. The spinner spins indefinitely.
- Handler cross-talk: two subsystems on one default port, each reacting to the other's replies with a confusing partial-state render.
- Unbounded queue growth from a producer that outruns the worker — a
mousemoveor scroll handler posting per event is the classic source (Passive Listeners). - A promise map that never gets cleaned up. Every timed-out request leaves its resolver, its arguments and its closure alive forever (Debugging Memory).
terminate()called to "cancel", dropping the worker mid-computation and leaving every other pending request in the map unresolved.- The mitigation failing: a request id filter that ignores stale replies but never clears the loading state, so the UI stays busy after the answer it discarded.
- Replies are in order but answers can be stale: reply N arrives after the user has moved on to request N+2. The id check is the only thing standing between that and a wrong render.
- A cancel message races the work it is cancelling. The worker may already be past its last check when the cancel lands, and will send a result anyway — so the page must discard it, not assume cancellation succeeded.
- Worker startup races the first message. Messages are buffered so nothing is lost, but asynchronous initialisation inside the worker (opening IndexedDB, compiling WebAssembly) is not, and the first handler can run before it is ready.
- Two ports have no ordering relative to each other. A "ready" signal on one port and data on another can be observed in either order (Happens-Before: The Edge That Makes a Write Visible in Concurrency).
- Both ends are the same origin, so nothing here is a trust boundary. A message from your worker is as trustworthy as the code you shipped, and no more.
- The exception is
window.postMessagebetween origins, which is a completely different API that happens to share a name. There, every handler must check `event.origin` before trustingevent.data, and'*'as a target origin leaks the payload to whatever document happens to be there. - A worker relaying data from a
fetchto the page has done no validation. Treat it as untrusted server output on arrival, not as trusted because it came from your own worker. - Never post a value into a message that you would not put in a log. Messages are visible to anyone with devtools open, which is the user — always (Session Replay and the Privacy It Costs).
- A
SharedWorkeris shared across every same-origin document, including ones in different tabs. Data posted through it can be observed by any of them, which is a data-partitioning question, not just an architecture one.
- "
postMessageis synchronous because the serialisation is." The serialisation blocks the sender; the delivery does not. Both facts matter and they are different facts. - "Messages can arrive out of order." Not on a single port — ordering is guaranteed. What arrives out of order is *usefulness*: an in-order reply to a request you no longer care about.
- "
terminate()cancels the request." It destroys the worker. Every other in-flight request dies with it, and you pay realm startup again on the next one. - "If the worker throws, my promise rejects." Only if you wired
onerrorto reject it. Nothing does that for you. - "One
onmessagehandler is enough." It is, until the second feature arrives. Then it is the source of a bug that looks like a race and is actually a routing mistake.
Measuring it, and what changes in the field
- The Performance panel shows a message hop as a serialise block on the poster and a
messageevent task on the receiver. A wide serialise block is a payload problem, not a protocol problem (A Mental Model of the Devtools). - Instrument round-trip time yourself: stamp
performance.now()on the request, measure on reply. That number is the one users feel, and it includes queueing you cannot see any other way. - A growing pending-request map is the direct measurement of missing backpressure. Log its size; it should return to zero when the UI is idle.
- Watch main-thread task duration for
messageevents. If deserialisation is showing up as a long task, the payload is the problem (Long Tasks).
- On a slow device, per-message overhead is a larger fraction of everything. Chatty protocols degrade much faster than payload-heavy ones (The Real Cost of JavaScript).
- With a large dataset, one big message beats a thousand small ones by a wide margin — but a message big enough to matter should probably be transferred rather than copied (Structured Clone and Transferables).
- When the main thread is busy, replies queue there even though the worker finished promptly. The user experiences worker latency that is entirely main-thread latency (What the Main Thread Owns).
- In a long-lived tab, an unbounded queue and an uncleaned promise map are slow leaks that only appear after an hour of use (Long-Lived Clients and Version Skew).
- The promise wrapper is real code with real bugs: an id counter, a pending map, timeouts, cleanup, error routing. It is worth writing once and never again per feature.
- Per-feature
MessageChannelports remove cross-talk at the cost of more setup and more objects to keep track of. - Batching improves throughput and worsens the latency of the first item. For interactive work the first result usually matters more than the total.
- Cooperative cancellation requires the worker's hot loop to check a flag, which costs a little in the loop and a lot in code clarity — but the alternative is
terminate(), which is not cancellation, it is demolition.
Where this applies
Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.
- GENERALThe messaging semantics — synchronous serialisation on the sender, queued task on the receiver, in-order delivery per port, buffered pre-startup messages — are specified and consistent across engines.
- SIMPLIFIEDThe model here treats each message as one clean hop. Real engines batch, may transfer large buffers by reference under the hood, and schedule the receiving task among other task sources by priority — so observed latency varies with what else that thread is doing.
Where the depth lives
This domain teaches the browser-side mechanism and hands the rest off.
- — Software Design — a request/response protocol over a one-way queue is the same design problem as an RPC layer, and it rewards the same discipline: one client, one place where ids and timeouts live, and no feature reaching for the transport directly.