Skip to the content.

The event loop, and why a busy function freezes the whole UI

The event loop, and why a busy function freezes the whole UI

JavaScript runs your code on a single thread. There is exactly one call stack, and while a function sits on it, nothing else can run — not a click handler, not a scroll, not the browser’s own paint. So when a page “freezes,” it is almost never the network or the framework: it is a function that took too long to return, holding the one thread everything else is queued behind. The event loop is the mechanism that decides what runs next, and once you can see it, the freeze stops being mysterious and becomes a thing you can design around.

The event loop moves queued tasks onto the one call stack A task queue on the left holds click, timer and render tasks. The event loop moves one at a time onto the single call stack, which must empty before the next task or a paint can run. A dot travels from the queue to the stack. task queue clicktimerrender event loop only when the stack is empty call stack (single) one frame at a time a long frame = frozen UI
The loop can only move the next task onto the stack once the stack is empty — so a function that never returns starves clicks, timers, and paints alike.

One long function blocks everything

Here is a synchronous loop that takes a couple of seconds. While it runs, the call stack is occupied, so the browser cannot process the button click that started it, cannot repaint, cannot even show a spinner you set just before it:

button.addEventListener("click", () => {
  status.textContent = "working...";   // this paint never happens until the end
  let total = 0;
  for (let i = 0; i < 2_000_000_000; i++) {
    total += i;                         // stack is busy for ~2s — UI is frozen
  }
  status.textContent = `done: ${total}`;
});

The "working..." text never appears, because the DOM change is only painted when the stack empties — and it does not empty until the loop finishes, at which point you overwrite it with "done". The user sees a dead page and then a result, with no feedback in between. That is the event loop working exactly as designed; the bug is that we never gave it a chance to breathe.

Yield the thread so the loop can run

The fix is to break the long work into chunks and return between them, letting the event loop process a paint and any queued clicks before the next chunk. A setTimeout(…, 0) re-queues the next slice as a fresh task, so the stack empties in between:

function sumInChunks(n, onProgress, done) {
  let i = 0, total = 0;
  function chunk() {
    const end = Math.min(i + 5_000_000, n);   // do a slice...
    for (; i < end; i++) total += i;
    onProgress(i / n);                          // ...report, then yield
    if (i < n) setTimeout(chunk, 0);            // next slice as a new task
    else done(total);
  }
  chunk();
}

Now the stack empties after each slice, the loop gets to paint the progress and respond to input, and the page stays alive. For work that genuinely must run without interruption — parsing a large file, heavy image processing — the right answer is to move it off the main thread entirely with a Web Worker, which runs on its own thread and messages results back, leaving the UI thread free for what it is for. And when the pauses you want are between user events rather than compute chunks, that is the job of debounce and throttle. The habit that matters is the same in every case: never hold the one thread longer than a frame, because everything the user can see or touch is waiting behind it. The debounce-utility exercise builds the timer-based yielding that keeps event handlers from monopolising it.