The question this answers
Which work must leave the thread that renders, and what can actually cross the boundary?
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.
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 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.
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.
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 = 05const 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 out10 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.data35 try {36 const result = parseAndSort(csv, column) // ~800 ms of CPU, off the paint thread37 // 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})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 post | Mechanism | Cost | Watch out for |
|---|---|---|---|
| Plain objects, arrays, Map, Set, Date, RegExp, TypedArray | Structured clone | O(size), on both threads | Cyclic references survive; a 30 MB graph costs more than the work |
| ArrayBuffer in the transfer list | Transfer | O(1) | Sender's buffer is detached — byteLength 0, later reads throw |
| ImageBitmap, OffscreenCanvas, MessagePort, ReadableStream | Transfer | O(1) | OffscreenCanvas lets the worker paint, so pixels never cross |
| Functions, class prototypes, closures, Proxy | Not clonable | — | DataCloneError at runtime; the error names nothing useful |
| DOM nodes, window, document | Not clonable, not available | — | A worker has no DOM at all; it must send data back and let the page render |
| SharedArrayBuffer | Shared memory, not copied | O(1), no copy ever | Requires cross-origin isolation headers; plain access is a data race — use Atomics |
| Errors thrown in the worker | Not propagated to the caller | — | Fires onerror on the Worker object; in-flight requests need their own timeout |
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. OffscreenCanvasis 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.
SharedArrayBufferneeds 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.
- •
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
postMessagein 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
messageevent 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.
- • 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;
onerrorfires; 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
SharedArrayBufferwithview[0]++: both load the same value and both store the same value, and one increment is lost, exactly as in Worker Threads.
- • 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:
SharedArrayBufferavailability. Without cross-origin isolation the constructor is simply not there.
- • 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.
- •
DataCloneErrorwhen 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
SharedArrayBufferaccessed withoutAtomics, on real cores, with lost updates. - • Memory pressure from a large
ArrayBufferexisting in both threads because it was cloned rather than transferred.
- • Parsing, sorting, filtering and aggregating large datasets in the page.
- • Image and video manipulation, especially with
OffscreenCanvasso 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.
- • 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.
- • 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.
- • 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
SharedArrayBufferis required, is an infrastructure change with consequences for every embedded third-party resource.
- • Chunk the work and yield between chunks with
scheduler.yield()orsetTimeout(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.
- •
requestIdleCallbackfor 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
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.
Scheduler timeline
What people believe, and what is true
Web workers make the page faster.
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.
I can update the DOM from a worker if I am careful.
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.
Offloading removed the jank, so we are done.
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.