skip to content

You are designing a browser feature that runs CPU-heavy work in dedicated Web Workers. How do you decide how many workers to create and whether they are long-lived or spawned per task, and what does `navigator.hardwareConcurrency` actually tell you?

level: principalimportance: nice to knowfreq 26%

answer

  1. does the work split at all
  2. a hint, not a budget
  3. reserve the main thread
  4. warm pool, capped low
  5. every pool needs an idle policy

basics

~20 s

Size a small pool, typically hardwareConcurrency minus one and capped low, and keep it long-lived rather than spawning per task. hardwareConcurrency reports logical processors visible to the page, not free capacity — user agents may clamp it, and the main thread and other tabs are competing.

solid answer

~60 s

Start from the shape of the work. Genuinely parallel, independent chunks justify a pool; a single serialised pipeline needs exactly one worker. For a pool, `navigator.hardwareConcurrency` is the only signal the platform gives you — it reports the number of logical processors available to the page, and it is a hint, not a budget: the main thread needs one, other tabs and the browser's own threads are competing, and some user agents clamp or round the value for fingerprinting resistance. So the usual shape is `Math.min(hardwareConcurrency - 1, someCap)` with a floor of one, and the cap is low, often two to four, because each extra worker buys less and costs a whole JavaScript heap. Prefer long-lived workers over per-task spawning: each construction pays thread creation plus script compile, and a pool amortises that across every job. The counterweight is memory and the need for an idle policy — on a phone, several resident heaps is a real cost, so shrink or tear down the pool when the feature goes idle or the page is hidden.

code

javascript · 10 lines
javascript
// Reserve the main thread, cap the pool, tolerate a missing value.
const reported = navigator.hardwareConcurrency || 4;
const poolSize = Math.max(1, Math.min(reported - 1, 4));

const pool = Array.from({ length: poolSize }, (_, i) =>
  new Worker(new URL('./job.worker.js', import.meta.url), {
    type: 'module',
    name: `job-worker-${i}`,
  })
);

go deeper

for a junior

Know that navigator.hardwareConcurrency reports how many logical processors are available and that creating a worker per task is more expensive than reusing one you already have.

for a middle

Explain what a worker costs to construct — thread creation plus script fetch and compile — and why reusing a warm worker removes that from every job. Be able to write a pool size that reserves the main thread and caps the total.

for a senior

Show the operating judgment: an idle policy so a pool is not a leak, shrinking when the page is hidden, replacing a worker after an error event, and terminating a worker whose job was superseded because it cannot observe a cancel message mid-computation.

for a principal

Own the decision to build a pool at all. Weigh one warm worker, a server-side precompute or a cached derived form against pool infrastructure, and be explicit about whether you are optimising workstation throughput or mid-range-phone responsiveness, since they give different answers.

## First: does the work parallelise at all? Before sizing anything, check the shape. A pool only helps if the work splits into independent chunks whose results can be recombined cheaply. Three common shapes: - **One long serial pipeline** — a single worker. Adding more just adds memory. - **Many independent jobs, arriving over time** — a small pool with a queue, so bursts are absorbed without spawning threads per burst. - **One big job that splits cleanly** — a pool sized to the split, but only if the per-chunk compute is large enough to dwarf the cost of shipping each chunk in and its result out. If splitting the job means each worker gets a large slice of data for a short computation, the handoff cost eats the parallelism and one worker is faster than four. ## What hardwareConcurrency means `navigator.hardwareConcurrency` is available on both `Window` and `WorkerNavigator` and returns the number of logical processors available to run threads for the page. Read it for what it is: - **It counts logical processors, not free ones.** Your main thread needs one. The browser has its own threads — networking, compositing, GPU, rasterisation. Other tabs and other applications are running. - **It says nothing about the cores' quality.** Mobile SoCs mix a few fast cores with several efficiency cores, so "eight" can mean two useful cores and six that will finish your chunk much later, leaving you waiting on the slowest one. Thermal throttling then makes the number a moving target. - **User agents may clamp or round it** for fingerprinting resistance, so the value is a hint about the device class, not a measurement you can trust to the unit. The practical translation: ```js const reported = navigator.hardwareConcurrency || 4; // fall back on a modest guess const poolSize = Math.max(1, Math.min(reported - 1, 4)); ``` The subtraction reserves the main thread. The cap encodes the diminishing return: past a handful of workers you are usually bounded by the handoff and by memory, not by cores. Pick the cap by measuring on the low-end device you actually care about, not on the laptop you develop on. ## Long-lived versus per-task **Per task** means constructing a `Worker` when a job arrives and terminating it when the job finishes. It is simple, self-cleaning, and has no idle memory cost. It pays thread creation plus script fetch, parse and compile on every job — the fetch is usually cached, the compile is not free — and it makes latency spiky. **Long-lived** means constructing the pool once and feeding it jobs. Startup is paid once, off the critical path if you initialise early, and per-job latency drops to the handoff alone. Warm workers also keep whatever they have JIT-compiled and any caches they built. The costs are real: each worker is a separate JavaScript realm with its own heap, so an idle pool of four is four heaps sitting resident, and workers hold their module state so a leak in worker code accumulates across jobs instead of being cleaned up by termination. For anything invoked more than occasionally, long-lived wins. Per-task spawning is defensible only for genuinely rare, one-shot work — an export or an import the user runs a few times a session. ## The idle policy A long-lived pool needs a rule for when it shrinks: - **Idle timeout** — terminate workers that have had no job for some period, keeping one warm. - **Lifecycle-driven** — shrink or drop the pool when the page becomes hidden, and rebuild lazily when it becomes visible again; a background tab has no business holding several threads. - **Feature-scoped** — tear the pool down with the feature that owns it, so a single-page app that never navigates does not accumulate pools from screens the user left. Without one of these, a pool is just a memory leak with better manners. ## Scheduling inside the pool With a pool you own a scheduler, and its policy matters more than its size. Decide up front: - **Queueing** — one queue feeding whichever worker is free, rather than round-robin assignment, so a slow job does not block a queue behind it. - **Cancellation** — a superseded job's worker can be terminated and replaced, because a worker busy in a synchronous computation will never see a cancel message. - **Backpressure** — a bound on the queue, and a decision about what to do when it is full: drop, coalesce, or refuse. - **Failure** — an `error` event on a worker should remove it from the pool and construct a replacement, rather than leaving a dead slot that silently swallows jobs. ## Deciding whether to build this at all A pool is infrastructure with its own bugs. Before adopting one, ask whether one long-lived worker is enough — it very often is, because the win came from getting off the main thread, not from using more cores. Ask whether the work should run on the server instead, or be precomputed and cached, which removes it from every device rather than spreading it across the cores of the weakest one. And be explicit about what you are optimising: throughput on a workstation and responsiveness on a mid-range phone lead to different pool sizes, and only one of them is usually the goal.

  • Why cap the pool well below the reported core count instead of using all of it?
    Because the returns fall off fast and the costs do not. The main thread and the browser's own compositing, networking and raster threads need cores; other tabs compete; and each extra worker is a whole JavaScript heap. Past a handful of workers you are usually bounded by the data handoff and by memory rather than by available cores, especially on mobile.
  • What does the `name` option on the Worker constructor buy a pool?
    Diagnostics. The name is readable inside the worker as `self.name` and labels the thread in browser devtools and performance traces, so a pool of otherwise identical workers becomes distinguishable when you are reading a profile or setting a breakpoint in the right one.
  • How should a pool handle a job that is superseded before it finishes?
    Terminate the worker running it and construct a replacement. A worker inside a synchronous computation never returns to its event loop, so it cannot observe a cancel message. If cancellation is frequent, design the job to return control between chunks so it can be stopped cheaply, and keep termination as the backstop for the genuinely runaway case.

saying these in an interview costs you the question

  • Treats hardwareConcurrency as guaranteed free capacity
  • Spawns one worker per job for frequent work
  • Sizes the pool to the core count with no cap
  • Keeps an idle pool alive in a hidden tab
  • Assumes all reported cores are equally fast

context