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.
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.