Topics
Concurrency & Async

Web Workers: Getting Work Off the Main Thread

Why one long JavaScript task freezes input, when yielding is enough, and how workers, transferables and SharedArrayBuffer move heavy work off the main thread.

Intermediate·13 min read·Updated Oct 7, 2026

A browser tab runs your JavaScript, style calculation, layout and paint on one main thread, so while a 300 ms function runs, a click cannot even start being handled. async does not help with that, because awaiting only reorders work on the same thread. The two real fixes are to split the work into short tasks that let input in between, or to move it to a Web Worker: a separate thread with its own event loop and no DOM, which talks to the page only by passing messages.

Context

JavaScript was designed in 1995 to run small scripts on one thread, and the web still guarantees that page code never runs in parallel with itself, which is why you never need locks around the DOM. As pages turned into applications, that guarantee became the bottleneck: one slow function freezes scrolling, typing and animations. Web Workers, part of the HTML5 work, shipped in Firefox 3.5 in 2009 and in every major browser soon after. They keep the guarantee intact by giving the extra thread nothing to share: a worker cannot touch the DOM or the page's variables, only exchange messages.

You have used them without noticing. VS Code for the web and the Monaco editor run language services in workers, PDF.js parses documents in one, Partytown moves third-party scripts into one, and Node.js offers the same model as worker_threads. The smallest worker is a file and a message:

hello-worker.js
// main.js
const worker = new Worker('square.js')
worker.onmessage = (e) => console.log(e.data)   // 49
worker.postMessage(7)

// square.js, running on its own thread
self.onmessage = (e) => self.postMessage(e.data * e.data)
Main thread
The one thread per page that runs your JavaScript, handles input events and renders. Everything that blocks it blocks the user.
Long task
Any main-thread task over 50 ms. Input that arrives during it waits until it ends; Chrome DevTools marks it with a red corner.
Dedicated worker
A thread owned by one page, created with new Worker(). It has its own global scope and event loop, and no access to the DOM.
Structured clone
The algorithm postMessage uses to copy data between threads. It handles objects, arrays, Maps, Dates and typed arrays, but not functions or DOM nodes.
Transferable
An object, such as an ArrayBuffer, whose ownership can move to another thread instead of being copied. The sender loses access to it.

Why it matters

Responsiveness is now measured, not guessed: INP (Interaction to Next Paint), a Core Web Vital since March 2024, records how long real users wait after tapping or typing, and the usual culprit is a long task that was already running. Parsing a large JSON response, filtering 50,000 rows on every keystroke, building a search index, resizing an image before upload or highlighting a big code block are all ordinary features that turn into frozen inputs on a mid-range phone. Knowing when to yield and when to move work to a worker is the difference between "we added a spinner" and a page that keeps responding.

One lane for everything

The main thread runs one task at a time to completion. A click that arrives during a 320 ms task is queued, and its handler starts only when the task ends; the browser cannot paint in between either. Marking the function async changes nothing if the expensive part is synchronous: the CPU work still runs in one uninterrupted stretch on the same thread.

main thread onlymainclickparse 2 MB JSON · 320 mshandlerthe click waits ~280 ms for the task to finishwith a workermainworkerpostMessageclickhandlerrender resultparse 2 MB JSON · 320 msthe main thread stays free: the click is handled at once
Top: a 320 ms parse on the main thread makes a click wait until it ends. Bottom: the same parse in a worker; the main thread only posts the data, handles the click at once and renders the result when it arrives.

Before reaching for a worker, try the cheaper fix: break the work into chunks and give control back between them, so pending input and rendering get a turn. The event loop topic covers why setTimeout or scheduler.yield() (Chromium 129, 2024; feature-detect it) yields and a resolved promise does not. Yielding is right when the work needs the DOM, is already short per item, or only occasionally runs long.

yield-loop.ts
const yieldToMain = () =>
  'scheduler' in globalThis && 'yield' in (globalThis as any).scheduler
    ? (globalThis as any).scheduler.yield()
    : new Promise<void>(r => setTimeout(r, 0))

export async function processAll(items: Item[]) {
  let deadline = performance.now() + 40     // stay under the 50 ms budget
  for (const item of items) {
    process(item)
    if (performance.now() > deadline) {
      await yieldToMain()                   // input and paint run here
      deadline = performance.now() + 40
    }
  }
}

Moving work into a worker

A worker is the right tool when the work is pure computation on data: no DOM, no layout, just CPU. The page creates the worker, sends it input with postMessage, and gets the result back in a message event. Module workers ({type: 'module'}, in every major browser since Firefox 114 in 2023) can use import, and Vite and webpack 5 bundle a worker automatically when you write new URL('./worker.ts', import.meta.url).

search.worker.ts
// runs on its own thread: no window, no document
self.onmessage = (e: MessageEvent<ArrayBuffer>) => {
  const text = new TextDecoder().decode(e.data)
  const rows = JSON.parse(text) as Row[]       // the slow part
  const index = buildIndex(rows)
  const hits = Int32Array.from(index.topIds)   // typed array: transferable
  self.postMessage(hits, [hits.buffer])
}
main.ts
const worker = new Worker(
  new URL('./search.worker.ts', import.meta.url),
  {type: 'module'},
)

const buf = await (await fetch('/data/catalog.json')).arrayBuffer()
worker.postMessage(buf, [buf])   // transfer, not copy
console.log(buf.byteLength)      // 0: the buffer now belongs to the worker

worker.onmessage = (e: MessageEvent<Int32Array>) => render(e.data)
worker.onerror = (e) => console.error('worker failed', e.message)

Copy, transfer or share

Every message crosses threads somehow, and the way it crosses decides the cost. By default postMessage runs the structured clone algorithm: it serializes the value on the sending thread and rebuilds it on the receiving one, so sending a 50 MB object costs main-thread time on the way out. Listing an ArrayBuffer, MessagePort, ImageBitmap or OffscreenCanvas in the second argument transfers it instead: ownership moves in constant time and the sender's copy becomes empty.

clone (default)
serialize on sendercopyrebuild on receiverO(size), both sides keep a copy
transfer
move ownershipO(1), sender’s buffer becomes empty
share
same memory, both threadsSharedArrayBuffer + Atomics
Three ways data crosses from the page to a worker. Cloning costs time proportional to the size; transferring is constant time but the sender gives the data up; sharing needs cross-origin isolation and your own synchronisation.

Sharing memory: SharedArrayBuffer and Atomics

For data that both sides keep working on, such as a large simulation grid or an audio buffer, copying or transferring back and forth is wasteful. SharedArrayBuffer gives both threads the same memory, and Atomics provides race-free reads, writes and waits on it. Browsers disabled it in January 2018 because shared memory makes a precise timer, which the Spectre attacks needed to read other sites' data. It came back only for pages that are cross-origin isolated (Firefox 79 in 2020, Chrome 92 in 2021): the page must send two headers that keep cross-origin documents and resources out of its process.

headers.http
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
shared-counter.ts
if (!crossOriginIsolated) throw new Error('needs COOP + COEP headers')

const sab = new SharedArrayBuffer(4)
const counter = new Int32Array(sab)
worker.postMessage(sab)          // shared: neither copied nor transferred

// in either thread
Atomics.add(counter, 0, 1)       // atomic increment, no lost updates
Atomics.notify(counter, 0)       // wake a worker blocked in Atomics.wait

// Atomics.wait() blocks, so browsers only allow it inside workers;
// the main thread uses Atomics.waitAsync() instead

Which worker, and what belongs off-thread

KindLifetime and scopeJobNotes
DedicatedOne page; ends with itCPU work for that pageThe default; new Worker()
SharedAll same-origin tabs that connectOne connection or cache for many tabsTalks through MessagePort; patchy on mobile
ServiceIndependent of pages; event-drivenNetwork proxy, offline, pushNot for computation; can be stopped any time
WorkletInside the rendering or audio pipelineAudio processing, custom paintTiny, specialised, no general messaging
Node.jsProcess; worker_threads (Node 12+)CPU work beside the event loopSame message model; I/O does not need it

Good candidates share a profile: a lot of computation, little data crossing the boundary, and no need for the DOM. Parsing or diffing large JSON, building or querying a client-side search index, image and video processing (with OffscreenCanvas, in all major browsers since 2023), syntax highlighting, compression, cryptography and WebAssembly computation all fit. Bad candidates are small tasks, where creating a worker (tens of milliseconds) and cloning messages costs more than the work, and anything that has to read or change the DOM in the middle.

  1. 1
    Measure first: record the interaction in the DevTools Performance panel and find the long task and the function inside it.
  2. 2
    If the work touches the DOM or is only occasionally long, chunk it and yield. Stop there if INP is fine.
  3. 3
    Otherwise move the pure computation into a module worker, sending inputs as transferables and getting back only what the page renders.
  4. 4
    Create the worker once, at startup or on first use, and reuse it. For many independent jobs, keep a small pool sized from navigator.hardwareConcurrency minus one.
  5. 5
    Measure again: the long task should be gone from the main thread and the worker's task should show on its own track.
hash-files.mjs
// Node.js: same model with worker_threads
import {Worker} from 'node:worker_threads'

export function hashInWorker(path) {
  return new Promise((resolve, reject) => {
    const w = new Worker(new URL('./hash-worker.mjs', import.meta.url),
      {workerData: {path}})
    w.once('message', resolve)
    w.once('error', reject)
  })
}

Pitfalls

  • Assuming async means off the main thread

    Promises, async functions and await schedule work on the same thread. A synchronous loop inside an async function still blocks every click until it ends; only yielding between chunks or a worker changes that.

  • Sending huge objects both ways

    Structured cloning a large object graph runs on the sending thread, so posting it from the page can itself be a long task, and posting a big result back costs the same again. Transfer typed arrays, send only what the page needs, and keep the bulky state inside the worker.

  • A new worker for every job

    Each worker is a separate JavaScript environment with its own startup time and memory. Spawning one per keystroke or per file is slower than doing the work inline. Create workers once and reuse them, or keep a small pool.

  • Turning on cross-origin isolation without an inventory

    COOP and COEP change how the whole page loads: popups lose window.opener and cross-origin resources without CORP headers stop loading. Ship the headers in report-only mode first, check what breaks, and only then rely on SharedArrayBuffer.

  • Silent failures inside the worker

    An exception in a worker does not appear in the page's error handling, and the promise the page is awaiting simply never resolves. Listen for error and messageerror, reply with an error message instead of throwing, and add a timeout on the page side.

Interview questions

Q1Why does making a function async not stop it from freezing the page?

Because async code still runs on the main thread. Awaiting only splits the function at the await points; the synchronous stretches between them run without interruption, and input and rendering wait. To stay responsive you either break the work into short tasks that yield back to the event loop, or run it in a worker, which is genuinely parallel.

Q2When would you yield and when would you use a Web Worker?

Yield when the work needs the DOM, when each step is small, or when it only occasionally runs long; it is a few lines and keeps everything on one thread. Use a worker when the work is pure computation that takes hundreds of milliseconds, such as parsing, indexing or image processing, especially on slow devices. The worker costs setup and message passing, so it must save more than that.

Q3Walk me through moving a slow client-side search index into a worker.

I would create one module worker at startup, fetch the data as an ArrayBuffer and transfer it to the worker, which parses it and builds the index there. Each query becomes a small message, and the worker replies with only the matching IDs, ideally as a transferred typed array, which the page maps to rows it already has. I would wrap it with Comlink or a tiny request-ID protocol, handle worker errors with a timeout, and confirm in the Performance panel that the main-thread long task is gone.

Q4What happens when you postMessage a 100 MB object to a worker?

The browser runs the structured clone algorithm: it serializes the whole object graph on the sending thread and rebuilds it in the worker, so the page can freeze while it serializes and memory briefly holds two copies. If the data can be an ArrayBuffer or typed array, transferring it moves ownership in constant time instead, at the price that the page can no longer read it.

Q5Why does SharedArrayBuffer require cross-origin isolation?

Shared memory lets one thread increment a counter in a tight loop while another reads it, which makes a high-resolution timer. That timer is what Spectre-style attacks need to read memory belonging to other origins in the same process. Browsers therefore only enable it when the page sends COOP and COEP headers, which guarantee no cross-origin document or resource shares its process without consent.

Q6How is a Service Worker different from a Web Worker?

A Web Worker is a computation thread owned by a page and lives as long as the page. A Service Worker sits between the origin's pages and the network: it intercepts requests, serves cached responses, enables offline use and push notifications, and the browser starts and stops it on demand. Neither has DOM access, but you would never run heavy computation in a Service Worker.

Q7In Node.js, when do you need worker_threads?

Only for CPU-bound work such as hashing, image resizing or parsing large files, because Node already handles I/O concurrently on its event loop and thread pool. worker_threads run JavaScript in parallel within one process and can share memory, which makes them lighter than child processes. For scaling an HTTP server across cores, cluster or multiple processes are still the usual tool.

Key takeaways
  • JavaScript, style, layout and paint share one main thread; any task over 50 ms delays input and hurts INP.
  • Async is not parallel. Either chunk the work and yield, or move pure computation into a worker.
  • Workers have no DOM and talk by messages. Cloning costs time per byte; transfer ArrayBuffers and canvases instead.
  • SharedArrayBuffer and Atomics need COOP and COEP headers, which also block non-cooperating cross-origin embeds.
  • Create workers once and reuse them; tiny tasks are faster inline than across a message boundary.
  • Service Workers proxy the network, Web Workers compute, and Node's worker_threads are the server-side equivalent.

Preparing for interviews? DevRecall turns a job description into a prep plan that points at topics like this one.

Start free