Skip to content

The UI freezes during a computation

The situation

The user clicks “Import” or “Generate report”, and the page dies for five seconds. No click works, the spinner freezes mid-frame, text can’t be selected. Then everything snaps back at once. Chrome may even offer to kill the page. The work completes — it just takes the UI hostage while it runs.

Diagnosis: why it happens

Rendering happens between tasks, never during one (see Runtime fundamentals and the render pipeline). A 5-second synchronous task = 5 seconds × 0 frames painted, 0 events handled. The freeze isn’t a slow pipeline — the pipeline never gets a turn.

The 50ms rule: tasks under ~50ms leave the UI feeling responsive (web.dev long tasks). One 5,000ms task is 100× over budget. And async/await alone doesn’t help — synchronous CPU inside an async function blocks identically.

Confirm the shape in DevTools → Performance: one long yellow block = this case. Many medium tasks (renders) = a different case (Your huge list renders slowly). And check what the block is: if it’s an accidentally-quadratic loop, fix that and this case may evaporate → Crossing two lists is slow.

Recommendation: chunk it, or move it off-thread

Two mechanically different fixes; pick by the work’s nature.

Chunking — same thread, but yield every slice so the browser can paint and handle input:

Before — one 5s task:

function processAll(rows: Row[]) {
for (const row of rows) processRow(row) // 5s, UI dead
}

After — 50ms slices, UI alive, progress for free:

async function processAll(rows: Row[], onProgress: (done: number) => void) {
const CHUNK = 500
for (let i = 0; i < rows.length; i += CHUNK) {
for (const row of rows.slice(i, i + CHUNK)) processRow(row)
onProgress(i + CHUNK)
await new Promise((r) => setTimeout(r, 0)) // yield to the event loop
// (or `await scheduler.yield()` where supported — same idea, better priority)
}
}

Don’t take our word for it — this demo really blocks this page’s main thread for 2 seconds. Watch the ball and the fps counter, and try clicking during each mode:

Worker — a real second thread; the main thread only posts input and receives the result. See the full pattern with code in A CPU-bound task blocks everything — that case is the runtime-level view of this same problem.

Choosing:

ChunkingWorker
UI stays responsiveYes (mostly — shares the thread)Yes (fully)
Total timeSlightly longerSame or shorter (true parallel)
Needs DOM accessWorksNo DOM in workers
Serialization costNoneCopies data both ways
Complexity~5 linesNew file, message protocol

When does it matter? Under ~100ms of blockage: a progress affordance is enough, don’t restructure. 100–500ms on a user-triggered action: chunking. Multi-second or recurring work: worker. Minutes-long: it doesn’t belong in the browser (→ backend).

Discarded alternatives (and when they ARE the right call)

Web Worker as the default reflex

  • What it solves: total isolation of the work from the UI thread.
  • Why it’s not the first option here: for moderate work, serialization overhead and the no-DOM constraint make it more machinery than the problem needs; chunking is 5 lines. (For genuinely heavy work it is the recommendation — see the decision table.)
  • When it IS the right call: work in the seconds range, pure data-in/data-out → A CPU-bound task blocks everything.

Moving the computation to the backend

  • What it solves: the client does nothing; results cacheable across users; the browser can’t run out of memory on it.
  • Why it’s not the first option here: round-trip latency, upload cost for local data (a 50MB file the user just picked), backend infrastructure, no offline.
  • When it IS the right call: the data already lives server-side, the result is shared, or the work takes minutes → then it’s a job queue, not a request (Heavy jobs block requests).

CSS-only feedback (spinner and hope)

  • What it solves: in theory, perceived responsiveness.
  • Why it’s not the first option here: it doesn’t work — CSS animations driven by the main thread freeze with it. (Compositor-driven animations may keep moving, which is worse: a lively spinner over a dead page.) Clicks still go nowhere.
  • When it IS the right call: never as the fix. As a companion to chunking/workers — progress bars, disabled buttons — always: the user should see that work is happening.

How to measure before and after

DevTools → Performance → record the action. Before: one long task (red triangle), frames row empty for its whole duration. After chunking: a comb of ~50ms tasks with paints between them. After a worker: main thread nearly idle, work visible in the worker lane.

Quick sanity check without the profiler: start the action, try to select text or hover a button. If the hover state responds, the loop is breathing.

In production: INP (target < 200ms) catches exactly this on real users.

Signals you need something else