WorkersGENERALSIMPLIFIED

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.

The question

How do the page and a worker actually communicate, and what does that protocol cost me?

The user intent

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.

The obvious build

Call worker.postMessage(query) and handle the answer in worker.onmessage. It is basically a function call with extra steps.

Why it breaks

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.

How it breaks in a real browser
  • 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 onmessage handlers 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 error handler 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).
IntentEventStateUI LogicDOM WorkNetworkLayout / PaintPixelsFeedback

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 a message event 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 message event 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).
  • MessageChannel gives you a pair of MessagePorts. 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.
  • onmessageerror fires when a message arrives that could not be deserialised. It is a distinct event from onerror, and almost nobody handles it, which is why "the worker went quiet" is such a common bug report.
  • A SharedWorker speaks only through ports: each connecting document gets a port via the connect event, 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 MessageEvent object 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.

One postMessage hop
  1. 1
    Serialise (sender thread)

    Structured clone walks the value and produces a serialised form.

    fails by Blocking the sender proportionally to payload size; throwing DataCloneError on a function, a DOM node or a class instance it cannot represent.

  2. 2
    Queue

    A message event 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.

  3. 3
    Wait

    The receiver finishes whatever task it is running.

    fails by A long task on the receiver delaying a message that was ready immediately.

  4. 4
    Deserialise (receiver thread)

    A fresh object graph is allocated in the receiving realm.

    fails by Blocking the receiver, again proportionally to size; firing messageerror if it cannot be deserialised.

  5. 5
    Dispatch

    onmessage runs, to completion, as one task.

    fails by An uncaught throw here rejects nothing on the other side — the requester waits forever.

  6. 6
    Correlate

    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.

A minimal request/response client over postMessage
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 = 0
7
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 reply
11 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.data
18 settle(id, (p) => (ok ? p.resolve(value) : p.reject(new Error(error))))
19 }
20
21 // three separate silences, all of which must reject something
22 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 = ++nextId
31 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.

Symptom, cause, response
TriggerSymptomCauseResponse
Worker handler throws on a malformed rowSpinner never stops; no console error visible to the userA throw inside a worker rejects nothing on the page and, without onerror, is reported nowhereCatch inside the worker and reply { ok: false }; also wire onerror, onmessageerror and a timeout (Frontend Error Tracking).
A message posted per input eventResults lag typing by a word; memory climbs during fast entryNo debounce and no backpressure — every keystroke is a queued job the worker will faithfully completeDebounce at the source, and have the worker check a cancel flag at loop boundaries.
A second feature added to the same workerFeature A renders feature B's data intermittentlyBoth features subscribed to the default port and both handlers run for every messageOne MessageChannel port per feature, or a strict type discriminator checked before anything else.
Slow request finishes after a fast oneTable shows results for a filter the user has already changedIn-order delivery guarantees nothing about relevanceCorrelate by request id and drop replies for anything but the current one (Five Components, One Request).
Stale reply correctly discardedRight data, but the spinner and aria-busy never clearThe id filter returned early, before the loading state was resetClear the busy state on every terminal reply, including the ones you throw away.
terminate() used to cancelUnrelated pending requests hang; the next interaction is noticeably slowerTerminating destroys the realm and every in-flight request in it, and the next call pays startup againCooperative 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 touches postMessage directly.
  • 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 MessageChannel ports rather than multiplexing everything through the worker's default port and switching on type.
  • 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, onmessageerror and 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. onerror should produce visible, focusable error text — not just a console.error that only a developer will see (Errors People Can Actually Perceive).

What can go wrong

Failure modes
  • 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 mousemove or 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.
What can arrive out of order
  • 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).
Security
  • 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.postMessage between origins, which is a completely different API that happens to share a name. There, every handler must check `event.origin` before trusting event.data, and '*' as a target origin leaks the payload to whatever document happens to be there.
  • A worker relaying data from a fetch to 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 SharedWorker is 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.
Misreads
  • "postMessage is 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 onerror to reject it. Nothing does that for you.
  • "One onmessage handler 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

How you would see this
  • The Performance panel shows a message hop as a serialise block on the poster and a message event 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 message events. If deserialisation is showing up as a long task, the payload is the problem (Long Tasks).
Slow device, slow network, large data, old tab
  • 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).
What this costs
  • 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 MessageChannel ports 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.

Securitycors
Performancetail-latency
Domains that do not exist yet
  • 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.