Skip to content

A CPU-bound task blocks everything

The situation

The user uploads a CSV, or you transform a big JSON, or you compute something genuinely heavy — and the whole page freezes. Not slow: frozen. The spinner you added doesn’t even spin. In Node, the equivalent: one endpoint does heavy work and the entire server stops responding to everyone else.

Diagnosis: why it happens

JavaScript runs your task to completion on the one thread that does everything else — input handling, rendering, other requests (see Runtime fundamentals). A 4-second parse is a 4-second window where the browser cannot paint a single frame. That’s why the spinner freezes: animating it requires the thread you’re hogging.

Making the function async changes nothing — await yields only at actual async boundaries. Synchronous CPU work inside an async function blocks identically.

First, be sure it’s actually CPU. Record in DevTools → Performance: one long yellow (scripting) block of hundreds of ms or seconds = CPU-bound, this case. Lots of small gaps waiting on network = I/O-bound, different problem (Many async operations at once).

Recommendation: move it to a worker

Workers are real separate threads with their own event loop. The main thread posts the input, keeps handling clicks and painting frames, and receives the result as a message.

Before — main thread blocked ~4s:

function handleFile(text: string) {
const rows = parseAndAggregate(text) // 4s of sync CPU
render(rows)
}

After — main thread free; work in parallel:

worker.ts
self.onmessage = (e) => {
self.postMessage(parseAndAggregate(e.data))
}
// main.ts
const worker = new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' })
worker.onmessage = (e) => render(e.data)
function handleFile(text: string) {
worker.postMessage(text) // UI stays interactive
}

In Node the same shape is worker_threads; for a server, prefer a small pool (e.g. piscina) over spawning per request — threads are ~MBs each and take ms to start.

Feel the difference — the “Block 2s” button really freezes this page (that’s the point), the worker button runs the identical work off-thread:

The honest cost: postMessage copies the data (structured clone). Shipping a 100MB object to a worker and back can cost more than the computation you saved. Mitigate by sending the raw input (a File/ArrayBuffer — buffers are transferable, moved not copied) and returning the small aggregated result, not the full parsed dataset.

When does it matter?

  • Task < ~50ms: nobody perceives it. No worker.
  • 50–200ms: perceptible on interactions that should feel instant. Chunking (below) is usually enough and much simpler.
  • > ~500ms, or repeated: worker territory — in Node, anything CPU-heavy on a server that serves concurrent users.

Discarded alternatives (and when they ARE the right call)

Fixing the algorithm first

  • What it solves: if the work is accidentally O(n²), making it O(n) can turn 4s into 40ms — and then no infrastructure is needed at all.
  • Why it’s not the first option here: it is the first option — always. This case assumes the work is legitimately heavy after that check.
  • When it IS the right call: always check first → Crossing two lists is slow. A worker wrapped around a quadratic loop is a slow program with better manners.

Chunking: slice the work and yield between slices

  • What it solves: process 500 rows, yield to the loop so it can paint and handle input, continue. No worker, no serialization, same-thread access to everything.
    for (const chunk of chunks(rows, 500)) {
    processChunk(chunk)
    await new Promise((r) => setTimeout(r, 0)) // yield (or scheduler.yield())
    }
  • Why it’s not the first option here: total time gets longer (the work time-shares with rendering), and it doesn’t help Node throughput — the loop is still spending most turns on your work.
  • When it IS the right call: moderate work (~50–500ms), or when the work needs DOM access (workers have none), or when the serialization cost of a worker would dominate. Progressive rendering (“show rows as they parse”) falls out naturally.

Moving it to the backend / a job queue

  • What it solves: the client does nothing; heavy work runs on servers sized for it, cacheable and shareable across users.
  • Why it’s not the first option here: latency (upload + wait + download), backend cost and complexity, and it can’t work offline. For one-off client-side transforms of data the user already has, a worker is simpler.
  • When it IS the right call: the work needs data that lives server-side anyway, results are reused across users, or it’s minutes-long → that’s a job queue (Heavy jobs block requests).

How to measure before and after

DevTools → Performance → record the interaction. Before: one long task (red-flagged) of seconds. After (worker): main thread shows only short tasks; the work appears under the worker’s own lane. Confirm the fix with feel: the spinner animates during processing.

console.time('parse') // inside the worker
const out = parseAndAggregate(input)
console.timeEnd('parse')

In Node: monitor event-loop delay with perf_hooks.monitorEventLoopDelay() — p99 in the hundreds of ms means the loop is being starved.

Signals you need something else