Off the Main Thread
Workers give you more threads. The catch is that every byte you hand them has to cross a border, and crossing costs time.
The story
An analytics app lets users upload a CSV export and explore it in the browser. A typical file is 40 MB, about 400,000 rows. Parsing it with a popular CSV library on the main thread freezes the tab for 3 seconds: the progress spinner doesn't spin, and Chrome sometimes shows "Page unresponsive".
The team moves parsing into a Web Worker. The spinner now spins during parsing. Then, at the end, the tab still freezes, for almost a second.
The profile shows the freeze isn't parsing any more. It's the line where the worker sends the result back:
postMessage(rows) // 400,000 objects × 12 fields each
postMessage copies what you send, using the structured clone algorithm. Copying 400,000 small objects means allocating and building 400,000 new objects on the main thread's heap, and that work lands on the main thread.
The second fix changed the shape of the data instead of the location of the work. The worker builds columns of typed arrays (one Float64Array per numeric column, plus a dictionary-encoded array for text) and transfers the underlying buffers instead of copying them:
postMessage({ price, qty, cityIdx, cities }, [price.buffer, qty.buffer, cityIdx.buffer])
The transfer takes almost no time regardless of size. The freeze disappeared, and the data used about a third of the memory.
Lesson: moving work to a worker is half the job. The other half is designing what crosses the border.
Part 1: What a worker is
A Web Worker is a real OS thread with its own JavaScript engine instance, its own heap and its own event loop. It runs a separate script and talks to the page only by passing messages.
What a worker can't do: touch the DOM, read layout, access window or document. What it can do: fetch, WebSocket, IndexedDB, timers, crypto, WebAssembly, and draw to an OffscreenCanvas.
// Modern bundlers (Vite, webpack 5, Next.js) understand this form:
const worker = new Worker(new URL('./parse.worker.ts', import.meta.url), { type: 'module' })
Three kinds of worker, three jobs
| Kind | Lives for | Use for |
|---|---|---|
| Dedicated worker | One page | Computation for that page (this chapter) |
| Shared worker | Shared by tabs of the same origin | One WebSocket connection shared across tabs (not in all browsers on mobile) |
| Service worker | Independent of pages, event-driven | Network proxy: caching, offline, push (FE-05). Not for heavy computation: the browser can stop it at any time. |
Part 2: What belongs in a worker
Good candidates are CPU-heavy, DOM-free, and big enough to be worth a message round trip:
Typical wins:
- Parsing: CSV, large JSON, Markdown, syntax highlighting.
- Data work: filtering, sorting, grouping and aggregating big datasets. Building a search index.
- Media: image resizing and filters, audio analysis, compression.
- Diffing and CRDT merges in collaborative editors.
- Rendering a chart or scene with OffscreenCanvas (Part 5).
Poor candidates: work that's already fast, work that needs the DOM on every step, and chatty work that would need hundreds of messages per frame.
Part 3: The cost of crossing the border
There are three ways data can cross, with very different costs:
Structured clone
The default. It handles objects, arrays, Maps, Sets, Dates, typed arrays and more (not functions or DOM nodes). It's a deep copy, so its cost grows with the amount of data and the number of objects. Many small objects is the worst case, as in the story.
Transferables
ArrayBuffer, MessagePort, ImageBitmap and OffscreenCanvas can be transferred: the memory moves to the receiver and the sender's reference becomes unusable (a transferred buffer has byteLength 0). The cost doesn't depend on size. To benefit, your data has to be buffers, which leads to the most useful habit in this chapter:
Store big data as columns of typed arrays.
// Row-oriented: 400k objects, each with its own allocation and hidden class
[{ price: 12.5, qty: 3, city: 'Pune' }, …]
// Column-oriented: 3 allocations total, transferable, cache-friendly
{
price: Float64Array(400000),
qty: Uint32Array(400000),
cityIdx: Uint16Array(400000), // index into…
cities: ['Pune', 'Mumbai', …], // …a small dictionary
}
Columnar data also makes filtering and aggregation faster: summing one Float64Array is a tight loop over contiguous memory. It's the same reason analytics databases store data in columns. Chapter 5 builds on this.
SharedArrayBuffer
Both threads see the same memory, with Atomics for safe coordination. It's the fastest option and the hardest to get right (data races are now your problem). Since the Spectre vulnerabilities, browsers only allow it on cross-origin isolated pages, which need two response headers:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp (or credentialless)
These headers can break third-party embeds, OAuth popups and ads, so check before enabling them. Most apps never need SharedArrayBuffer. Transferables cover the common cases.
Part 4: Working with workers in practice
RPC instead of raw messages
Raw postMessage with switch (msg.type) gets messy fast. Comlink (a ~1 KB library from Google Chrome Labs) wraps a worker so you call its functions like async functions:
// worker
import * as Comlink from 'comlink'
Comlink.expose({ parseCsv: (text) => parse(text) })
// main
const api = Comlink.wrap(new Worker(new URL('./csv.worker.ts', import.meta.url), { type: 'module' }))
const table = await api.parseCsv(text)
Requests, cancellation and pools
- Tag each request with an ID and ignore stale responses. If the user changes the filter twice, the first result must not overwrite the second.
- Cancel long jobs by checking a flag between chunks, or by terminating the worker if the work is truly abandoned (
worker.terminate()is instant, but you lose its state and pay startup again). - Pool workers for parallel jobs, sized from
navigator.hardwareConcurrencyminus one for the main thread. Splitting work across a pool only helps when the work divides cleanly (chunks of rows, tiles of an image). - Start workers early (during idle time after load) if their startup cost would otherwise land on the first interaction.
Big inputs: send the source, not the result
If the data comes from the network, let the worker fetch it directly. The raw bytes never touch the main thread at all, and only the small, processed result comes back.
Part 5: OffscreenCanvas — rendering from a worker
A <canvas> can hand control of its drawing surface to a worker:
// main thread
const offscreen = canvas.transferControlToOffscreen()
worker.postMessage({ canvas: offscreen }, [offscreen])
// worker
onmessage = ({ data }) => {
const ctx = data.canvas.getContext('2d') // or 'webgl2'
function frame() {
draw(ctx)
requestAnimationFrame(frame) // available in dedicated workers that own an OffscreenCanvas
}
frame()
}
Now a chart can keep animating even while the main thread is busy with React renders or a long task, and the main thread stays free for input. Two limits to remember:
- Input still arrives on the main thread. You forward pointer and wheel events to the worker yourself, and hit testing (which point is under the cursor?) happens in the worker.
- No DOM in the worker, so tooltips and labels that are HTML elements stay on the main thread and need the worker to report positions.
OffscreenCanvas has been available in all major browsers since Safari added it in 16.4 (2023). Older Safari versions need a main-thread fallback.
Part 6: WebAssembly — when it's worth it
WebAssembly runs compiled code (C, C++, Rust, Go) at near-native speed, and it runs happily inside a worker. It pays off when:
- You'd be reimplementing an existing native library: image codecs (Squoosh), SQLite in the browser, PDF rendering, physics, video processing.
- The work is a tight numeric loop over large buffers, where predictable performance without JIT warm-up matters.
- You need one engine across platforms. Figma's editor core is C++ compiled to WebAssembly, which is a large part of why it can handle huge documents in a browser.
It doesn't pay off for DOM-heavy work, or for small jobs where copying data into WebAssembly memory and back costs more than the computation. Modern JIT-compiled JavaScript working on typed arrays is often close to WebAssembly for simple loops, so measure before rewriting.
Numbers to remember
These vary a lot by device. Treat them as orders of magnitude and measure on a mid-range phone.
| Thing | Rough cost |
|---|---|
| Starting a worker | Tens of ms (script download + parse). Start it before you need it. |
A small postMessage round trip | Well under 1 ms |
| Structured clone of a few KB | Negligible |
| Structured clone of hundreds of thousands of objects | Hundreds of ms, on both threads |
Transferring an ArrayBuffer | ~Constant, regardless of size |
| Useful worker pool size | navigator.hardwareConcurrency - 1 |
The DevTools workflow
- Performance panel: each worker gets its own track under the main thread. Confirm the heavy work moved, and look at the main thread around the moment the result arrives. A long "Deserialize" or GC block there is the clone cost.
- Console context dropdown: switch to the worker to inspect its globals.
- Memory panel: take heap snapshots per worker context. Workers that are never terminated and keep large caches are a common leak (FE-07).
- Time the boundary explicitly:
performance.markbeforepostMessagein the worker and in theonmessagehandler on the main thread.
Cheat sheet
| Situation | Do |
|---|---|
| Heavy parse / sort / aggregate freezing the UI | Move it to a dedicated worker |
| Result is huge | Typed-array columns + transfer the buffers |
| Data comes from the network | Let the worker fetch it directly |
| Messy message protocol | Comlink |
| Stale results overwrite fresh ones | Request IDs. Ignore old responses. |
| Chart must stay smooth during main-thread work | OffscreenCanvas in a worker. Forward input events. |
| Shared memory between threads | SharedArrayBuffer + Atomics, with COOP/COEP headers |
| Existing C/C++/Rust library | WebAssembly in a worker |
| Offline / caching | Service worker, not a dedicated worker |
| Tiny or DOM-bound work | Keep it on the main thread |
Say it in the interview
"The CSV is parsed in a dedicated worker that fetches the file itself, so the raw bytes never touch the main thread. It builds typed-array columns with dictionary-encoded strings and transfers the buffers back, which costs nearly nothing whatever the size, unlike structured-cloning 400k objects. Filtering and aggregation stay in the worker behind a Comlink API, with request IDs so stale results are dropped. The chart renders from the worker with OffscreenCanvas, and the main thread only handles input and the React chrome."
Go deeper
- The Main Thread and Scheduling: its simulation's third tab runs the same work in a worker on your device.
- FE-05 · Network and Caching: service workers.
- FE-07 · Memory Leaks: long-lived workers and their heaps.
- JS · Controlling Concurrency: pools and limits, the same ideas on the async side.