skip to content

A team moved a 400 ms parse-and-transform step off the main thread into a dedicated Web Worker, and the interaction still feels janky. What costs does moving work into a worker introduce, and how do you decide between a worker and splitting the work up on the main thread?

level: seniorimportance: should knowfreq 44%

answer

  1. three new costs: startup, handoff, latency
  2. compute per byte transferred
  3. the DOM half never moved
  4. profile after, not before
  5. warm worker beats per-click worker

basics

~20 s

A worker adds thread startup, a data handoff whose cost scales with payload size, and a round trip of latency. It only helps if the main thread's long task was the computation itself — if the remaining jank is DOM work or rendering the result, the worker moved the wrong 400 ms.

solid answer

~60 s

Offloading is not free. You pay to start the thread and fetch and compile its script, you pay to move the input in and the result out — that handoff copies data, so the cost scales with payload size, not with how long the computation takes — and you pay a round trip of latency before anything appears. So the first thing to check is whether the main thread still has a long task: profile it. Very often the parse moved but the *application* of the result did not, and building thousands of DOM nodes or forcing layout is what the user is actually feeling. The decision rule is a ratio: offload when compute time is large relative to the data you must ship across, the work never touches the DOM, and the user can tolerate a round trip. Chunk on the main thread instead when the work is inherently DOM-bound, when the payload is huge relative to a short computation, or when the result is needed for the very next frame. And keep the worker warm rather than spawning one per interaction, so startup is paid once.

go deeper

for a junior

Know that a worker moves computation off the main thread but that anything touching the page still has to run on it, and that starting a worker and moving data across are not free.

for a middle

Explain the three costs — startup, data handoff proportional to payload size, and round-trip latency — and be able to say why a change that moved a parse into a worker can leave the rendering half of the same interaction just as slow.

for a senior

Demonstrate the diagnosis: profile the interaction after the change, identify what the remaining main-thread long task actually is, and state the ratio you use to decide between offloading and chunking. Mention keeping the worker warm and shrinking what crosses the boundary.

for a principal

Own the framing that a worker relocates work rather than reducing it. Weigh precomputing on the server or caching the derived form against buying a thread, and set the expectation for low-end devices where the cores are fewer and slower.

## The three costs you just took on Moving work to a worker replaces one cost with three. **Startup.** `new Worker(url)` creates a real thread and a new JavaScript realm, then fetches, parses and compiles the worker script and its module graph. On a warm cache this is often single-digit to low tens of milliseconds; on a cold cache on a slow phone it can be much worse. If you construct the worker inside the click handler, that cost lands squarely inside the interaction you were trying to speed up. **Handoff.** The page and the worker share no objects. Getting data across means copying it, and the cost is proportional to the size and shape of the payload rather than to how long the computation takes. Shipping ten megabytes into a worker so it can spend two milliseconds on it is strictly worse than doing the work inline. The ratio that matters is *compute time per byte transferred*. **Latency.** Even when everything is fast, the result now arrives asynchronously, at least one turn later on each side. Work that must be ready for the current frame — measuring, then positioning something before the next paint — cannot be moved across that boundary without dropping the frame. ## Why the jank survived The usual explanation is that the profiled 400 ms was not the whole story. Take a shape like this: ```js const data = JSON.parse(text); // 400 ms — moved to the worker const rows = data.map(toViewModel); // 120 ms — did it move too? renderTable(rows); // 600 ms of DOM construction — cannot move ``` Only the first line was a candidate. The mapping may or may not have gone with it, and `renderTable` never could: creating nodes, inserting them and letting the browser lay them out is main-thread work by definition. A worker cannot make the DOM faster; it can only stop *other* work from competing with it. The other classic case is that the worker handed back an enormous object and the copy on the receiving side became its own long task. You moved the compute and kept a proportional cost. So the diagnosis is always the same: record a performance profile of the interaction *after* the change, look at the main thread track, and find what the remaining long task actually is. If the long task is now DOM or style/layout work, the worker was the wrong lever. If the main thread is genuinely idle and the interaction still feels slow, the problem is latency or startup, not blocking. ## The decision rule Offload to a worker when: - The work is CPU-bound and *sustained* — hundreds of milliseconds or more, or repeated often enough to add up. - The input and output are small relative to the compute. Parsing a 2 MB file into a 50 KB summary is ideal; passing 50 MB through a trivial filter is not. - The work touches no DOM. Text processing, search indexing, diffing, compression, crypto, decoding binary formats, image processing on an `OffscreenCanvas`. - A round trip of latency is acceptable — the user is waiting on a result anyway, and a spinner is honest. Chunk on the main thread when: - The work *is* DOM work. Split node creation across frames, virtualise the list, or render less. There is no thread to escape to. - The payload dwarfs the computation. - The result is needed synchronously for the next frame. - The total work is small enough that yielding a few times keeps every task under the responsiveness threshold anyway. These are not exclusive. The strongest shape is usually both: the worker computes a compact view model, and the page applies it incrementally across frames rather than in one blocking burst. ## Making the worker pay off If you keep the worker, three changes usually recover the win: 1. **Create it early and keep it.** Construct the worker when the feature loads, not when the button is clicked, so startup is off the critical path. A single long-lived worker beats one per interaction. 2. **Move the boundary, not just the function.** Push as much of the pipeline across as you can, and return the smallest useful result — ideally something the page can apply almost directly. Returning a compact typed array or a short list of rendered strings beats returning the full object graph. 3. **Shrink what crosses.** Have the worker do the filtering, sorting and slicing so the page receives only the page-worth of data it is about to display, and fetch the rest on demand. ## The honest failure mode Sometimes the answer is that the work should not happen in the browser at all. If a 400 ms parse runs on every load of the same document, the shape of that document is the problem: paginate it, precompute the summary on the server, or store the derived form in IndexedDB so subsequent loads read the result instead of recomputing it. A worker hides main-thread jank; it does not reduce the work, and on a low-end phone with fewer, slower cores the work is still there and still competing with everything else the device is doing.

  • How would you confirm from a performance profile that the worker actually helped?
    Record the interaction and look at the main thread track specifically. The signal is that the long task corresponding to the computation is gone from the main thread and appears on the worker's own thread, while the interaction's total blocking time drops. If the main thread still shows a long task, read what it is — usually DOM construction, style recalculation or the receive-side cost of a large result.
  • When is a worker the wrong answer even for genuinely heavy computation?
    When the payload dwarfs the compute, when the result is needed within the current frame, when the work is DOM-bound, or when the same expensive result is recomputed on every load and should instead be cached in IndexedDB or precomputed on the server. A worker relocates work; it never reduces it, which matters most on the low-end devices you were trying to help.
  • Why does spawning a worker inside a click handler undermine the whole point?
    Because thread creation plus fetching, parsing and compiling the worker script all land inside the interaction you were optimising, adding latency exactly where the user is watching. Constructing the worker when the feature initialises makes that a one-time cost paid off the critical path, and the same instance then serves every subsequent request.

saying these in an interview costs you the question

  • Assumes any slow function gets faster in a worker
  • Ignores that the handoff cost scales with payload size
  • Creates a fresh worker for every user interaction
  • Forgets DOM rendering stays on the main thread
  • Thinks a worker reduces total work rather than relocating it

context