Async & Event Loops

Web Workers

The browser version of the same bargain, with a harder constraint: the main thread also renders. Anything over roughly 50 ms on it is a dropped frame or an unresponsive click, so the question is not "is this slow?" but "does this belong on the thread that paints?".

▶ Run the lab

The question this answers

The question

Which work must leave the thread that renders, and what can actually cross the boundary?

The work

A data-grid page that parses a 30 MB CSV, sorts 400,000 rows and computes aggregates — while the user expects scrolling to stay smooth and a filter click to respond immediately.

What is shared

Nothing by default: the worker has no DOM, no window, no access to page objects. What crosses is structured-cloned copies or transferred buffers. The page and the worker share only what you explicitly put in a SharedArrayBuffer, which requires cross-origin isolation headers to even be available.

The invariant — what must stay true under every interleaving

The main thread never runs a task long enough to miss a frame or delay an interaction — practically, no task exceeds the frame budget, and the interaction-to-next-paint stays within target regardless of dataset size.

Synchronization exists to preserve this sentence. If a schedule can make it false, the code is wrong no matter which primitive it uses.

WorkCan it overlap?Can it parallelise?What is shared?What ordering?What synchronization?Where is contention?What can deadlock?What can race?What is gained?What complexity?

The main thread has a second job, and it is the one users see

In Node, blocking the loop delays other requests. In the browser, blocking the main thread also stops style, layout, paint and input handling — the same thread does all of it. A 900 ms sort is not "a slow function"; it is 54 dropped frames at 60 Hz and a click that appears to do nothing.

That changes the threshold at which offloading is worth it. Server-side, you offload when the work is large relative to other requests. Client-side, you offload when the work exceeds the frame budget, which is about 16 ms at 60 Hz, and the browser flags anything over 50 ms as a Long Task. The relevant user-facing metric is interaction-to-next-paint, and it is measured at the 98th percentile of interactions — so the one slow sort matters even if the median is fine. See UI Concurrency: One Thread Owns the Screen and core-web-vitals.

The timeline below is the entire argument. Same total work, same single CPU-second; the difference is which lane it occupies.

Parse + sort 400k rows: on the main thread versus in a worker. Spans are shape, not measurement.ILLUSTRATIVE
Main thread — work inline
parse + sort (input queued, no frames)
render result
User input — work inline
click at t=2, no visual response
handled
Main thread — work in a worker
transfer buffer (O(1))
frames, scroll, input — responsive
render result
Worker thread
idle
parse + sort
postMessage(result, [buffer])
↑ user clicks↑ result ready in both
runningreadywaitingblockedidle1 tick ≈ 100 ms

Getting data across without paying for it twice

The boundary is the design problem. Structured clone is a deep copy on both sides, so shipping 30 MB of parsed row objects back to the page can cost more than the sort did. The pattern that works is: send the *raw bytes* in, do the heavy transform in the worker, and send back either a small summary or a typed array you transfer.

Transferable objects — ArrayBuffer, MessagePort, ImageBitmap, OffscreenCanvas, and streams — move in constant time. OffscreenCanvas is the strongest version of this idea: hand the canvas itself to the worker and let it paint, so pixels never cross the boundary at all.

Two practical notes. Workers load as separate scripts, so use new Worker(url, { type: 'module' }) and keep the worker's dependency graph small — its startup is on the critical path the first time you use it. And errors do not propagate: an uncaught throw inside a worker fires onerror on the Worker object and nothing else, so a request that will never be answered needs its own timeout.

1// --- page.ts -------------------------------------------------------------
2const worker = new Worker(new URL('./grid.worker.ts', import.meta.url), { type: 'module' })
3
4let nextId = 0
5const pending = new Map<number, { resolve: (v: Float64Array) => void; reject: (e: Error) => void }>()
6
7worker.addEventListener('message', (e: MessageEvent<{ id: number; ok: boolean; result?: Float64Array; error?: string }>) => {
8 const entry = pending.get(e.data.id)
9 if (!entry) return // a response to a request we already timed out
10 pending.delete(e.data.id)
11 e.data.ok ? entry.resolve(e.data.result!) : entry.reject(new Error(e.data.error))
12})
13
14// The worker can die; nothing else will reject these promises.
15worker.addEventListener('error', (e) => {
16 for (const [, entry] of pending) entry.reject(new Error('worker crashed: ' + e.message))
17 pending.clear()
18})
19
20function sortInWorker(csv: ArrayBuffer, column: number, timeoutMs = 10_000): Promise<Float64Array> {
21 const id = nextId++
22 return new Promise((resolve, reject) => {
23 pending.set(id, { resolve, reject })
24 setTimeout(() => {
25 if (pending.delete(id)) reject(new Error('sort timed out'))
26 }, timeoutMs)
27 // csv is TRANSFERRED: O(1), and csv.byteLength is 0 on this side afterwards.
28 worker.postMessage({ id, csv, column }, [csv])
29 })
30}
31
32// --- grid.worker.ts ------------------------------------------------------
33self.addEventListener('message', (e: MessageEvent<{ id: number; csv: ArrayBuffer; column: number }>) => {
34 const { id, csv, column } = e.data
35 try {
36 const result = parseAndSort(csv, column) // ~800 ms of CPU, off the paint thread
37 // Transfer the result back too, so the page pays no copy on receipt.
38 ;(self as unknown as Worker).postMessage({ id, ok: true, result }, [result.buffer])
39 } catch (err) {
40 ;(self as unknown as Worker).postMessage({ id, ok: false, error: String(err) })
41 }
42})
Request/response over a worker with transfer, a timeout, and correlation ids.

What can and cannot cross the boundary

Most worker bugs are boundary bugs: someone posts an object holding a function, a class instance, or a DOM node, and it throws DataCloneError at runtime with a message that names nothing useful. Knowing the table below saves an afternoon.

The SharedArrayBuffer row deserves its own warning. Since the Spectre mitigations it is only available on cross-origin-isolated pages, which requires Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp — headers that will break third-party embeds and images that do not opt in. It is a deployment decision before it is a concurrency decision, and once you have it, plain reads and writes across threads are a real data race exactly as in Worker Threads.

What you postMechanismCostWatch out for
Plain objects, arrays, Map, Set, Date, RegExp, TypedArrayStructured cloneO(size), on both threadsCyclic references survive; a 30 MB graph costs more than the work
ArrayBuffer in the transfer listTransferO(1)Sender's buffer is detached — byteLength 0, later reads throw
ImageBitmap, OffscreenCanvas, MessagePort, ReadableStreamTransferO(1)OffscreenCanvas lets the worker paint, so pixels never cross
Functions, class prototypes, closures, ProxyNot clonableDataCloneError at runtime; the error names nothing useful
DOM nodes, window, documentNot clonable, not availableA worker has no DOM at all; it must send data back and let the page render
SharedArrayBufferShared memory, not copiedO(1), no copy everRequires cross-origin isolation headers; plain access is a data race — use Atomics
Errors thrown in the workerNot propagated to the callerFires onerror on the Worker object; in-flight requests need their own timeout
Crossing the worker boundary: cost and legality.

Key points

  • The main thread also renders and handles input, so the offload threshold is the frame budget, not "is this the biggest job".
  • A worker has no DOM and no window; it computes and sends data back, and the page renders it.
  • Structured clone is a deep copy on both sides — sending a large object graph back can cost more than the computation.
  • Transferable objects (ArrayBuffer, ImageBitmap, OffscreenCanvas, MessagePort, streams) move in O(1) and detach on the sender.
  • OffscreenCanvas is the strongest form of the pattern: hand over the canvas so pixels never cross the boundary.
  • Worker errors do not reach the caller — correlate requests by id and give every one of them a timeout.
  • SharedArrayBuffer needs cross-origin isolation headers and brings genuine data races with it.

The loop, answered

Every field is required, which is why no lesson here can recommend concurrency without naming the interleaving that breaks it, the complexity it adds, and the simpler thing to consider first.

How it works
  • new Worker(url, { type: 'module' }) starts a separate thread with its own global scope, its own event loop and no access to the page's objects.
  • Communication is postMessage in both directions; each message is delivered as a task on the receiver's loop.
  • Structured clone serialises the payload unless the object is listed in the transfer list, in which case ownership moves and the sender's handle is detached.
  • The worker runs on a real OS thread, so its CPU work proceeds while the main thread renders.
  • Results arrive as a message event on the main thread — an ordinary task, so a large result still costs deserialisation time on the thread you were protecting.
  • Terminating with worker.terminate() stops the thread immediately with no unwinding; there is no cooperative cancellation unless you build one over messages.
Interleavings that matter
  • Main thread sorts inline; a click arrives 200 ms in; the event sits in the queue for 700 ms and is dispatched after the sort — the handler runs correctly, and the user already clicked twice.
  • Main transfers the CSV buffer and then a retry path reads csv.byteLength; it is 0 and the retry throws — ownership moved and the retry logic assumed a copy.
  • Worker finishes and posts back 30 MB of row objects; the main thread spends 300 ms deserialising, so the frame drop moved rather than disappeared.
  • A request times out on the page and the entry is removed; the worker answers 200 ms later and the message handler finds no pending entry — correct only because the handler checks.
  • The worker throws; onerror fires; without the bulk-reject in the error handler, every in-flight promise stays pending forever and the UI shows a spinner with no failure.
  • Two workers increment a counter in a SharedArrayBuffer with view[0]++: both load the same value and both store the same value, and one increment is lost, exactly as in Worker Threads.
What it guarantees — and does not
  • Guaranteed: the worker cannot touch the DOM or any page object, so no UI state can be corrupted by worker code.
  • Guaranteed: messages on one port pair arrive in order.
  • Guaranteed: the worker runs on its own thread, so its CPU time does not consume the main thread's frame budget.
  • Guaranteed: transfer is constant time and the receiver gets the original bytes.
  • NOT guaranteed: that offloading improves anything. If the payload is big and the compute is small, the copies dominate.
  • NOT guaranteed: error delivery. An exception in the worker does not reject the caller's promise; you build that yourself.
  • NOT guaranteed: that the page stays responsive after the result arrives — deserialising a huge result is main-thread work.
  • NOT guaranteed: SharedArrayBuffer availability. Without cross-origin isolation the constructor is simply not there.
Where contention appears
  • The main thread is the contended resource, and its competitors are your handlers, the browser's style/layout/paint work and input dispatch.
  • Worker startup — thread creation plus module fetch, parse and evaluate — is on the critical path of the first request; warm the worker before the user needs it.
  • Serialisation on both ends is CPU contention on the very threads you are trying to protect.
  • Too many workers on a device with few cores (a mid-range phone often has two or four usable) produces the same oversubscription as anywhere else, plus memory pressure that can get the tab killed.
How it fails
  • DataCloneError when posting functions, class instances or DOM nodes.
  • Detached-buffer error after a transfer, usually in a retry or logging path that assumed a copy.
  • Silent hang: worker crashes, in-flight promises never settle, the spinner spins forever.
  • Frame drops moved rather than removed, because the result payload is deserialised on the main thread.
  • Long Task on the main thread from initialization work that was never offloaded — the worker exists and the slow part is still inline.
  • Data race on a SharedArrayBuffer accessed without Atomics, on real cores, with lost updates.
  • Memory pressure from a large ArrayBuffer existing in both threads because it was cloned rather than transferred.
When it helps
  • Parsing, sorting, filtering and aggregating large datasets in the page.
  • Image and video manipulation, especially with OffscreenCanvas so the pixels never cross.
  • Cryptography, compression, and WASM-heavy compute — hashing a large file inline will drop frames every time.
  • Anything where the result is small relative to the input: send bytes in, get a summary out, and the boundary cost stays negligible.
When it hurts
  • Work that is already fast; a round trip plus two clones for 3 ms of compute is a regression.
  • Work needing the DOM — layout measurement, getBoundingClientRect, canvas 2D on a normal canvas — none of it exists in a worker.
  • Large results that must come back as object graphs, where clone cost exceeds the saving.
  • Low-end devices with few cores, where a worker pool competes with the main thread for the same silicon.
How you would know
  • Long Tasks (PerformanceObserver on longtask) before and after — the count of main-thread blocks over 50 ms is the number that should drop.
  • Interaction to Next Paint at p98, since the metric is defined on the worst interactions, not the median. See core-web-vitals.
  • Time split per request: post → worker start → compute → post back → deserialise. The last segment is where "we offloaded it and it is still janky" hides.
  • Total blocking time in a synthetic run, which is the aggregate of everything over the 50 ms threshold.
  • Memory: peak heap with and without transfer, to confirm the buffer is not living in both threads.
Complexity it introduces
  • A second module graph with its own bundling, its own imports and its own startup cost, which must stay small.
  • A hand-written request/response protocol — ids, timeouts, error mapping, crash recovery — because none of it comes for free.
  • Every payload is now a serialisation contract that fails at runtime rather than at build time.
  • Debugging spans contexts: the worker has its own console and its own stack traces, and profiler traces need to be read per thread.
  • Cross-origin isolation, if SharedArrayBuffer is required, is an infrastructure change with consequences for every embedded third-party resource.
Simpler alternatives
  • Chunk the work and yield between chunks with scheduler.yield() or setTimeout(0) — no worker, no serialisation, and input gets a chance between chunks. Often enough.
  • Do less: virtualise the list, paginate, or filter server-side. Sorting 400,000 rows in the browser is frequently a data-shape problem rather than a threading problem.
  • Do it on the server and send the result, when the data is already there and the round trip is cheaper than the compute.
  • requestIdleCallback for genuinely non-urgent work, which runs it in the gaps without adding a thread.
  • WASM on the main thread for a constant-factor win — worth it only if the work then fits inside the frame budget, which for a 900 ms sort it will not.

CPU parallelism simulator

Scaling 100 CPU tasks
100 independent tasks of 20 ms each. The tasks do not share anything — the job around them does.
SIMULATEDA composed model, not a benchmark.

Amdahl’s term, a synchronisation term, an oversubscription term and a bandwidth ceiling, each one a knob you can switch off. Real curves have more causes than four and are rarely this smooth. There is no ideal core count to read off this chart.

Cores
The serial part is the split and the merge, not the tasks. The sync term is what each worker pays to coordinate with the others. The ceiling is where the memory system stops feeding cores, whatever the core count says.
1 workerdashed = linear speedup16 workers · max 16.0×
ideal
4.0× · 500 ms
Amdahl only
3.48×
modelled
3.28× · 610 ms
efficiency
82%
Where the 4× went
delivered3.3×
lost to the serial part0.5×
lost to sync, switching and bandwidth0.2×
At 4 cores the model delivers 3.28× of a possible 4×, so 109 ms of the run is overhead rather than work. The serial part dominates. Splitting the input, merging the results and the one section that cannot overlap now cost more than the cores save — and no core count fixes that term.
One hundred tasks that share nothing still do not scale linearly, because the job that owns them is not the tasks. Read the gap between the dashed line and the curve as the price of coordination — and note it is charged even when every task is independent.
limited by: serialSIMULATED

Scheduler timeline

Scheduler timeline
Tasks over cores, one tick per column. Watch which lanes run, which sit ready, and which are blocked on I/O.
Task 1
ready
ready
blocked
ready
Task 2
ready
ready
blocked
ready
ready
ready
ready
Task 3
ready
ready
ready
ready
ready
blocked
Task 4
ready
ready
ready
ready
Task 5
ready
ready
ready
ready
ready
ready
runningreadywaitingblockedidle1 column = 1 scheduler quantum
running now
1 / 1
ready queue
4
blocked on I/O
0
context switches
0
Ready-queue depth4 waiting for a core
One core: exactly one lane is `running` in every column, yet several tasks advance across the run. That is concurrency without parallelism — the definition, drawn.
A switch is counted whenever a core’s occupant changes between columns; the model charges 0.05 ms for each one. Real switch cost depends on the cache footprint the outgoing task leaves behind and is usually worse than a constant. Mechanism lives in Operating Systems — this view is about what the schedule means.
1/40 · tick 1SIMULATED

What people believe, and what is true

Claim

Web workers make the page faster.

Reality

They make it *responsive*. The total CPU is the same or slightly higher; what changes is that the paint thread is free while it happens.

Claim

I can update the DOM from a worker if I am careful.

Reality

There is no DOM in a worker. The only route back is a message, and rendering it is main-thread work you must keep small.

Claim

Offloading removed the jank, so we are done.

Reality

Check the receive side. A 30 MB structured-cloned result deserialises on the main thread and can be a Long Task all by itself.

Go deeper

Overview

A second thread with no DOM. Send it data, it computes, it sends data back, and the thread that paints stays free.

Practical

Transfer buffers in and out, keep results small, correlate requests by id, time them out, and handle the worker crashing.

Advanced

OffscreenCanvas moves rendering itself off the main thread, so neither pixels nor row objects ever cross. SharedArrayBuffer removes the copy entirely at the cost of cross-origin isolation and a genuine memory model.

Internals

Each worker is a separate agent with its own event loop and heap in the same process. Structured clone is a graph serialiser that preserves cycles and identity within one message; transfer neuters the source object and re-maps the backing store into the receiving agent.

Apply it