When a Worker Is Actually the Answer
Workers cost startup, message overhead, a second bundle and much harder debugging — and most slow frontends are not CPU-bound, so a worker fixes nothing.
The intent, the obvious build, and why it breaks
Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.
Is this slowness actually CPU work on the main thread, or am I about to pay for a worker that will not help?
A person says the dashboard "feels slow". They want it to stop feeling slow. They have no opinion about threads.
The page is janky and workers make things run off the main thread, so move the heaviest-looking function into a worker and see if it helps.
The heaviest-looking function was not the problem. The profile shows the main thread busy in style recalculation and layout, triggered by a component that reads offsetHeight in a loop — work a worker cannot do at all (Layout Thrashing).
- The heaviest-looking function was not the problem. The profile shows the main thread busy in style recalculation and layout, triggered by a component that reads
offsetHeightin a loop — work a worker cannot do at all (Layout Thrashing). - The slowness is network waiting, not computing. The main thread was idle the whole time; a worker adds a thread that also waits (Reading a Network Waterfall).
- The function moved successfully, and the page is still janky because the *result* is 10,000 DOM nodes and the main thread has to create every one of them (What a Mutation Costs).
- It got worse: the operation now includes worker startup, a structured clone of the input, and a clone of the result — on a fast connection, more total time for a small dataset (Structured Clone and Transferables).
- The bundle grew. Shared dependencies were duplicated into the worker chunk, so the page downloads and compiles them twice (Bundle Analysis).
- A production error in worker code arrives with no useful stack, because source maps for the worker bundle were never uploaded and the error handler was never wired (Source Maps).
What is actually happening
In the browser, not in the framework.
- A worker helps with exactly one thing: long, synchronous, CPU-bound JavaScript on data you already have. That is a narrow category, and most frontend slowness is outside it (Computing or Waiting? in Performance).
- Slowness has at least five distinct sources, and each has its own fix: network waiting, main-thread CPU, rendering work (style, layout, paint), DOM volume, and memory pressure. Only the second is a worker's business.
- Rendering work is not offloadable. Style, layout, paint scheduling and the accessibility tree are main-thread by construction, so
content-visibility, containment and doing less are the tools there (CSS Containment). - Waiting is already off-thread.
fetchdoes not occupy the main thread while it waits; async is not parallelism, and a worker adds nothing to work that is idle (Async Is Not Parallelism in Concurrency). - Setup is not free: a realm, a heap, an event loop, plus fetching, parsing and compiling a second script. For short tasks this dominates the work itself (Parallel Overhead in Concurrency).
- The alternatives to a worker are often better for interactive work. Yielding between chunks with a scheduling API keeps the main thread responsive without a boundary, at the cost of total throughput (Yielding and Scheduling).
- Where a worker is unambiguously right, it is very right: parsing a large file, running a WebAssembly kernel, cryptography, image processing, search indexing, and diffing a large document.
What this makes the browser do
And which of it is avoidable.
- Worker path: fetch, parse and compile a second script; allocate a realm; serialise the input; deserialise it; compute; serialise the result; deserialise it; then do the DOM work anyway.
- Main-thread path with yielding: the same computation, interleaved with rendering opportunities, so frames get produced in between (The Rendering Opportunity).
- Neither path changes the cost of the DOM update that follows. If that is the expensive part, both are the wrong fix.
- Both paths still pay style and layout for whatever changed. The worker does not make the browser's rendering work smaller — only your JavaScript's share of the thread (The Cost of a Change).
Name the slowness before you move it
There is no useful general answer to "should this be in a worker" — there is only an answer per bottleneck, and the bottleneck is a measurement, not an intuition. The options below are the five things "slow" usually turns out to mean, and four of them are untouched by a worker.
Work through it honestly. The Performance panel's summary tells you which row you are in within about thirty seconds, and that thirty seconds is worth more than any amount of reasoning about threads.
The Performance panel shows a long task or a dropped frame. Where is the time?
when Parsing a large file, a WebAssembly kernel, crypto, image processing, indexing, diffing — work on data you already have, touching no DOM, running well beyond a frame.
cost A worker: startup, two clones per round trip, a second bundle, two debugging contexts. Worth it here, and only here.
when A render pass, a moderate transform, work that is interactive rather than batch.
cost Yield instead. Lower overhead, one code path, and the total takes slightly longer (Yielding and Scheduling).
when A large table, a deep tree, a component reading geometry in a loop.
cost Not offloadable at any price. Containment, content-visibility, batching reads and writes, fewer nodes (Layout Thrashing).
when Thousands of nodes created, moved or restyled per interaction.
cost Virtualise or paginate. A worker computes the same list faster and the main thread still has to build it (List Virtualization).
when The user is waiting on data, not on computation.
cost A data problem: parallelise requests, remove a waterfall, cache, or ask the server for less (Reading a Network Waterfall).
when Retained listeners, detached nodes, an unbounded cache.
cost A worker adds a heap and makes this worse. Find the retention (Memory Leaks).
The honest cost of the boundary
When a worker *is* right, it is still not free, and the version people imagine is not the version they ship. The comparison below is the same feature written twice: once as it is usually pitched, and once with the things production requires.
The second block is not padding. Every line in it corresponds to a failure that happens without it: a realm per keystroke, a silent throw, a stale render, a browser where construction failed, an unreadable production stack.
// "just move it to a worker"
const w = new Worker('./analyse.js')
w.postMessage(rows)
w.onmessage = (e) => render(e.data)
// what this actually does:
// - a new realm per call site invocation
// - a full structured clone of `rows`, on the main thread
// - no error handling: a throw in the worker is silence
// - no correlation: a stale reply renders over a fresh one
// - no fallback if construction fails (CSP, blocked URL)
// - no source maps: production stacks are unreadable// one realm, reused; created off the interaction path
let client: WorkerClient | null = null
const getClient = () => (client ??= createWorkerClient(WORKER_URL))
export async function analyse(rows: Float64Array, signal: AbortSignal) {
const buf = rows.buffer // transfer, do not clone
try {
const client = getClient()
return await client.call<Result>('analyse', { rows }, [buf], signal)
} catch (err) {
report(err) // onerror + messageerror + timeout
return analyseSync(rows) // a fallback that is actually tested
}
}
// plus, outside this file:
// - worker source maps uploaded with every release
// - the worker chunk in the bundle report, deps not duplicated
// - a live region on the main thread for progress and completion
// - request ids so a stale result never reaches the DOMThe pitch measures well in a demo and fails in the four ways production fails: repeated realm creation, silent errors, stale renders and no path for users where construction fails. The extra code is not ceremony — each line is a specific outcome you would otherwise ship.
Reading the panel and deciding in thirty seconds
This is the whole method compressed. Record an interaction, look at the summary breakdown of the long task, and match it to a line below. Nothing here requires knowing anything about threads; it requires knowing which stage the browser was in.
Keep the last check. Tabbing through the page during the operation is a faster and more honest signal than any panel: if focus moves and status is announced, the main thread is free, and if it is not, no amount of worker track activity changes that.
- Four conditions, all of them: the time is in scripting; the work is necessary; it touches no DOM; it runs long enough that startup and two clones are noise beside it.
- Reuse the realm. One module-level client, created away from the interaction path, terminated on teardown — never one per call.
- Move bytes, not graphs. Transfer an
ArrayBuffer, or let the worker fetch the data itself (Structured Clone and Transferables). - Wire the failures.
onerror,onmessageerror, a timeout, uploaded source maps, and a fallback path that is exercised in tests rather than assumed. - Keep the DOM decisions on the main thread. The worker returns data; the page decides what that data looks like, and announces it (Live Regions and Announcement).
- Re-measure in the field. Total CPU may rise while responsiveness improves. That is a success, and only field data will show it (Real User Monitoring).
Record the interaction. Open the long task. Read the summary.
Scripting ████████████████████ your functions, one deep stack
Rendering ██
Painting █
System / idle █
-> CPU-bound on data you hold. WORKER (or yield, if it is near a frame).
Scripting ███
Rendering ██████████████████ Recalculate Style + Layout
Painting ███
-> Main-thread-only work. A worker cannot help. Containment, fewer
nodes, batch the reads and writes.
Scripting ██
Rendering ██
Painting █
Idle ██████████████████ waiting on the network
-> Not a CPU problem at all. Fix the waterfall or the payload.
Scripting ████████ many short tasks, none individually long
-> Death by a thousand cuts. Batch, memoise, or do less; a worker
adds a hop to each one and makes it worse.
Then, whatever the panel said, run the check that costs nothing:
press Tab while the operation is running.
Focus moves + status announced -> the main thread is free.
Nothing moves -> it is not, whatever you offloaded.How to build it
Most important first.
- Profile first, and name the stage. Open the Performance panel, find the long task, and say out loud whether the time is in scripting, rendering, painting or idle. A worker is only indicated for scripting (Measure Before Optimising).
- Then ask whether the work is necessary at all. Doing less — fewer rows, less eager computation, a cheaper algorithm, a server-side aggregate — beats moving it every time (List Virtualization).
- If it is scripting and it is necessary and it does not touch the DOM and it runs long enough to matter, use a worker. All four conditions, not three.
- For interactive work under roughly a frame, prefer yielding to moving. The scheduling cost is lower and you keep one debuggable code path (Yielding and Scheduling).
- Create the worker once and reuse it — a module-level singleton, or a small pool if the work genuinely parallelises. Never one per interaction (Worker Pools Beyond Threads in Concurrency).
- Have the worker fetch its own data where it can. Removing the hand-off is worth more than optimising it (Structured Clone and Transferables).
- Wire the boring infrastructure on day one:
onerrorto your error tracker, worker source maps uploaded, a timeout, and a synchronous fallback for browsers or contexts where construction fails (Frontend Error Tracking).
Keyboard, focus, semantics, announcement
A required field on every lesson in this domain, not a section added when there is room.
- This is a decision with a real accessibility dimension on both sides. Correctly applied, a worker keeps the main thread available, so the accessibility tree stays current, focus moves when the user asks, and live regions announce on time (The Accessibility Tree).
- Wrongly applied, it adds a boundary and changes nothing a user of assistive technology experiences, because the blockage was in rendering or DOM volume all along — and those still freeze the accessibility tree exactly as before.
- Whichever you choose, the announcement obligation does not move. A worker has no DOM and cannot announce; the main thread must own a live region that exists before the operation starts and receives its text from a message (Live Regions and Announcement).
- Long operations need an accessible busy state regardless of which thread they run on:
aria-busyon the affected region, a disabled control whose accessible name explains why, and focus restored to a sensible place when it finishes (Focus Management). - The cheapest check for whether your fix worked is not a profile: tab through the page while the operation runs. If focus moves and the status is announced, the main thread is free. If it does not, it is not — whatever the worker track says.
What can go wrong
- A worker introduced for slowness that was never CPU-bound — the most common outcome, and it costs a sprint plus permanent complexity.
- A worker per interaction, so a fast typist creates dozens of realms and the page gets slower the more it is used.
- Duplicated dependencies inflating the total download, so first load regresses in exchange for a smoother interaction later (Code Splitting).
- Silent worker errors: no
onerror, no source maps, no reporting. The feature "sometimes does not work" and nobody can see why. - A worker that becomes a second application — holding state, caching, making decisions — until answering "where does this state live?" requires reading both sides (Who Owns This State?).
- The mitigation failing: correct offload, main thread free, and the interaction still janky because rendering 10,000 nodes is the actual cost.
- No fallback. Worker construction can fail — CSP, a blocked script URL, an exhausted worker limit — and a feature with no synchronous path simply does not work for those users.
- A result can arrive after the user has navigated away or changed the input. Correlate by request id and check that the target still exists before touching the DOM (Talking to a Worker).
- A worker created lazily on first use races the interaction that needed it: the first call pays startup and can feel slower than the version it replaced.
- During deployment, a page can hold a worker from the previous release while its next chunk comes from the new one. Content-hashed URLs and a version check in the handshake avoid a protocol mismatch (Content-Hashed Assets).
- A worker adds a second script to your supply chain and a second place a dependency executes with your origin's authority (Third-Party Scripts and the Supply Chain).
- It is not an isolation boundary. Moving risky parsing into a worker does not contain it — same origin, same cookies, same storage (Origins and the Sandbox).
- The worker script URL is subject to CSP
worker-src. A policy tightened later can break worker construction in production without any application code changing (Content Security Policy). - Errors crossing the boundary are strings, not exceptions. Whatever you log from the worker is what you will have to debug with, so do not put user data in it (Session Replay and the Privacy It Costs).
- Anything the client computes remains a client computation. Moving a validation or a price calculation into a worker does not make it authoritative (What the Frontend Is Responsible For in Auth).
- "It is slow, so it needs a worker." Most frontend slowness is rendering, DOM volume or network. Workers address none of those.
- "Workers are free performance." They add startup, message overhead, a second bundle, a second debugging context and a permanent architectural seam.
- "Move everything off the main thread." The main thread must do the DOM work, and the DOM work is frequently the expensive part. There is a floor and you cannot get under it.
- "More workers means more speed." Beyond the number of cores, and often well before it, you are paying coordination costs for no gain (More Threads Is Not More Speed in Concurrency).
- "A worker is like
async."asyncyields while waiting; a worker runs on another thread. Making a slow synchronous functionasyncdoes not stop it blocking (Async Is Not Parallelism in Concurrency). - "We measured it locally and it was fine." Your machine is near the top of your users' device distribution, and the decision is entirely about where the median device spends its time.
Measuring it, and what changes in the field
- The Performance panel's summary breakdown — scripting versus rendering versus painting versus idle — is the single measurement that decides this. Anything else is a guess (A Mental Model of the Devtools).
- A long task whose flame chart is one deep synchronous call stack of your own functions is the shape that indicates a worker (Reading a Flame Graph in Performance).
- A long task that is mostly "Recalculate Style" and "Layout" indicates the opposite: nothing there can leave the main thread (Style Invalidation).
- Idle main thread with a long network bar indicates neither: this is a data-fetching problem (Reading a Network Waterfall).
- After shipping, compare interaction responsiveness in the field rather than locally. A worker can improve responsiveness while increasing total CPU, and only field data reflects the devices that needed the help (Real User Monitoring).
- On a fast device, the crossover payload — where the offload beats the overhead — is much larger than on a mid-range phone. A worker that looks pointless on a laptop can be decisive on the median device (The Real Cost of JavaScript).
- On a slow network, the worker's own script is an extra request before the feature can start; preloading matters, and for rare interactions it may not be worth it (Resource Hints).
- On a low-core device, parallelism is limited and the win narrows to responsiveness alone — still valuable, but do not promise throughput (Amdahl's Law in Concurrency).
- With a large dataset the case strengthens quickly, up to the point where the hand-off itself becomes the bottleneck and the real answer is to let the worker fetch the data.
- In a long-lived tab, a leaked worker per route is a compounding cost that only shows up after extended use (Long-Lived Clients and Version Skew).
- Two execution contexts, permanently. Every future engineer must know which side a given function runs on, and the type system will not tell them (TypeScript in the Build).
- Debugging gets meaningfully harder: stacks stop at the boundary, breakpoints live in another context, and error reporting needs explicit plumbing (A Method for Frontend Bugs).
- Build complexity: a second entry point, a second chunking strategy, and shared dependencies that either duplicate or need careful splitting.
- Total CPU goes up while perceived responsiveness goes down. On a battery-powered device that is a real cost paid for a real benefit — worth stating rather than hiding.
- The alternative, yielding, costs total throughput instead: the same work takes longer in wall-clock time but never blocks a frame. Which cost you prefer depends on whether the user is waiting or interacting.
Where this applies
Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.
- GENERALThe decision criteria — profile the stage first, offload only long synchronous CPU work, reuse workers, keep the DOM on the main thread — hold across every browser, because they follow from the one-thread-owns-the-DOM model rather than from any implementation.
- DEVICE-SPECIFICThe payload size at which offloading wins differs by an order of magnitude between a development laptop and a mid-range phone, and on a single-core device a worker improves responsiveness while making total wall-clock time slightly worse. Decide from field data on your device distribution, not from a local measurement.
Where the depth lives
This domain teaches the browser-side mechanism and hands the rest off.
- — Testing & Reliability Engineering — the fallback path is the part that is never exercised and always needed. A worker feature with no test that forces construction to fail has a fallback in name only.
- — Software Design — a worker is a module boundary enforced by the runtime. The same questions apply as to any boundary: who owns the state, what crosses it, and what happens when the other side is gone.