skip to content

You have to process a 200,000-item array in the browser and doing it in a single loop freezes the page. How do you use setTimeout to split that work so the page stays responsive, and what does the resulting code look like?

level: middleimportance: should knowfreq 50%

answer

  1. one long task is the whole problem
  2. many short tasks instead of one
  3. cursor plus a time budget
  4. reschedule only while items remain
  5. keep the id so it can be cancelled

basics

~20 s

Keep a cursor into the array and process only as many items as fit in a small time budget, roughly 5 ms. When the budget is spent, call setTimeout with the continuation and a zero delay so the next batch runs as a fresh task, letting queued work run in between.

solid answer

~50 s

The freeze happens because the whole loop is one task and nothing else can run until it returns. The fix is to make it many short tasks. Keep an index into the array, run a `while` loop that stops when a time budget of a few milliseconds is exhausted or the data runs out, and if items remain call `setTimeout(run, 0)` to schedule the next batch; otherwise finish. Between batches the runtime is free to dispatch queued input and other timers. Budget by *time*, not by a fixed item count — item cost varies across machines and across data. Keep the timer id so the job can be cancelled with `clearTimeout` when the user navigates away or the input changes. Total wall-clock time goes up slightly, which is the deliberate trade: responsiveness bought with throughput.

code

javascript · 26 lines
javascript
function processInChunks(items, work, onDone) {
  let i = 0;
  let timerId = null;

  function run() {
    const start = Date.now();
    while (i < items.length && Date.now() - start < 5) {
      work(items[i]);
      i++;
    }
    if (i < items.length) {
      timerId = setTimeout(run, 0);
    } else {
      onDone(i);
    }
  }

  run();
  return function cancel() { clearTimeout(timerId); };
}

const data = Array.from({ length: 200000 }, (_, n) => n);
let sum = 0;
const cancel = processInChunks(data, n => { sum += n; }, count => {
  console.log('done', count, sum);
});

go deeper

for a junior

Know that a single long loop blocks everything and that the fix is to do a slice at a time, scheduling the next slice with a zero-delay setTimeout.

for a middle

Write the runner from scratch: cursor outside the function, an inner loop bounded by elapsed time, reschedule only while items remain, and keep the timer id.

for a senior

Justify the budget with measurements, handle cancellation and mid-flight data changes, and state plainly that responsiveness is bought at the cost of total throughput.

for a principal

Argue about where the work belongs at all: whether main-thread chunking is the right answer versus reshaping the algorithm, paginating, or precomputing, and what responsiveness budget the product actually needs.

## Why the single loop freezes JavaScript runs one task to completion. A `for` loop over 200,000 items is one task, so from the moment it starts until it returns, no queued callback can run — not the click handler the user just triggered, not a pending timer, nothing the runtime wanted to do between tasks. The thread is not "busy in the background"; it is unavailable. Splitting the work does not make the CPU faster. It converts one long unavailability into many short ones, and the runtime gets a turn in every gap. ## The shape of a chunked runner ```js function processInChunks(items, work, onDone) { let i = 0; let timerId = null; function run() { const start = Date.now(); while (i < items.length && Date.now() - start < 5) { work(items[i]); i++; } if (i < items.length) { timerId = setTimeout(run, 0); } else { onDone(); } } run(); return () => clearTimeout(timerId); // cancel handle } ``` Four things are load-bearing here. **The cursor lives outside `run`.** Each invocation is a separate task with a fresh stack; the only thing carrying progress across tasks is the captured `i`. **The inner loop is bounded by time, not by count.** `while (i < items.length && Date.now() - start < 5)` adapts automatically: on a slow device it processes fewer items per batch, on a fast one more. A hard-coded `for (let n = 0; n < 500; n++)` guesses at per-item cost and guesses wrong on some machines and some data. **Rescheduling happens only while work remains.** Scheduling unconditionally leaves a timer chain running forever. **The timer id is kept.** A long job frequently outlives its reason to exist — the user navigated, the filter changed, a newer job started. Without `clearTimeout` you get two chained jobs writing over each other's results. ## Choosing the budget A few milliseconds per batch is the usual starting point. Two forces pull against each other: - Longer batches mean fewer yields, less scheduling overhead and better total throughput — but each batch is a window in which the thread is unavailable, so long batches reintroduce the very jank you are fixing. - Shorter batches yield more often, but each yield costs a scheduling hop, and hosts enforce a minimum delay, so a very fine-grained chain spends most of its wall clock waiting rather than working. Measure rather than guess: record `Date.now() - start` (or `performance.now()` deltas) for a sample of batches and check that the observed batch duration matches the budget you set. If a *single item* blows the budget, chunking at item granularity will not help — the unit of work itself has to be broken down. ## What chunking does and does not buy It buys responsiveness: between batches the runtime can dispatch a queued click and fire other timers. It does not buy speed. Total wall-clock time gets *worse*, because you add a scheduling hop between every batch and because other queued work now genuinely interleaves with yours. That is the intended trade, and it is worth saying out loud in an interview — a candidate who claims chunking makes the job finish sooner has not understood the mechanism. It also does not make the work concurrent. Everything still runs on the single thread, and a batch is just as uninterruptible as the original loop was, only shorter. ## Async sugar over the same mechanism The same structure reads more linearly inside an `async` function, as long as the awaited thing is a real timer: ```js const yieldToLoop = () => new Promise(resolve => setTimeout(resolve, 0)); async function process(items, work) { let start = Date.now(); for (let i = 0; i < items.length; i++) { work(items[i]); if (Date.now() - start >= 5) { await yieldToLoop(); start = Date.now(); } } } ``` This is the same chunking, not a different technique: the `setTimeout` inside `yieldToLoop` is what ends the task. Awaiting an already-resolved promise instead would only queue a microtask and would never release the thread. ## Failure modes to name Mutating the source array while a chunked job is in flight makes the cursor point at the wrong element — snapshot the data or re-validate on each batch. Starting a second job without cancelling the first gives you two interleaved runs. And writing results incrementally across many batches produces visibly progressive updates, which is sometimes desirable and sometimes a bug; if it matters, accumulate results and commit once at the end.

  • Why budget each batch by elapsed time instead of by a fixed number of items?
    Because per-item cost is not knowable in advance. The same 500-item batch might take 1 ms on a desktop and 30 ms on a low-end phone, or vary tenfold across the data itself. A time budget self-corrects: it processes whatever actually fits in the window you are willing to block for, so the responsiveness guarantee holds everywhere, not only on the machine you tested on.
  • Does chunking make the total job finish faster?
    No — slightly slower. You add a scheduling hop between every batch, the host's minimum delay makes each hop cost real milliseconds, and other queued work now interleaves with yours. What you buy is that the thread is available between batches, so input and updates are handled promptly. Throughput is traded for responsiveness deliberately.
  • What has to happen if the user navigates away or changes the input mid-run?
    Cancel the pending continuation with `clearTimeout` using the stored id, and guard the callback with a flag if a batch might already be running. Otherwise the chain keeps consuming CPU for results nobody wants, and if a new job starts you end up with two runs interleaving and overwriting each other's output.

saying these in an interview costs you the question

  • Says chunking makes the total work finish faster
  • Chunks by a hard-coded item count tuned on one machine
  • Reschedules the next batch unconditionally, so the chain never stops
  • Discards the timer id and cannot cancel the job
  • Thinks the batches run concurrently rather than one after another

context