JAVASCRIPT / PROMISES AND ASYNC PATTERNS
The event loop and the single-threaded model
Predict what runs now versus later in JavaScript by tracing the call stack and the event loop, and see why blocking code freezes timers, paint and input.
What you will learn
- Trace which lines run on the current stack and which are only queued for later
- Explain why setTimeout(fn, 0) can fire hundreds of milliseconds late
- Spot blocking loops that stall timers, painting, and click handling
- Chunk long work so the loop gets a turn between the pieces
Understanding The event loop and the single-threaded model
A JavaScript engine gives your code one call stack and one thread to run it on. When a function is called its frame is pushed onto that stack, and nothing else in your program can execute until that frame and everything it called have returned. setTimeout, fetch and DOM listeners are not exceptions to this rule: they belong to the host, the browser or Node, and the only thing they do synchronously is register a callback somewhere outside your stack.
The event loop is what connects those two worlds. Its cycle is roughly: if the stack is empty, take the oldest ready job from a queue, push it onto the stack, run it until it returns, then look again. That is why a delay of 0 means queue this as soon as possible rather than run this now; the callback becomes eligible immediately but still waits for the current stack to drain. Asynchronous here means later on the same thread, not at the same time as something else.
The practical consequence is that every long synchronous stretch is a freeze for everything else. In a browser, style recalculation, layout, painting and the dispatch of clicks and keystrokes are all work competing for that one thread, so a 300 ms sort costs roughly twenty missed frames at 60Hz and leaves clicks sitting unhandled. The concurrency you do get comes from the host running timers, sockets and disk operations elsewhere while JavaScript is idle; genuinely parallel JavaScript needs a Web Worker, which is a separate thread with its own stack, its own loop and no shared access to your variables or the DOM.
function blockFor(ms) {
const stop = Date.now() + ms;
while (Date.now() < stop) {} // busy-wait: the one thread stays occupied
}
const start = Date.now();
setTimeout(() => {
const waited = Date.now() - start;
console.log('timer callback ran');
console.log('waited at least 200ms:', waited >= 200);
}, 0);
console.log('stack still busy');
blockFor(200);
console.log('stack finally empty');JavaScript runs one task at a time to completion on a single call stack, and the event loop can only start the next queued callback once that stack is empty.
Worked examples
Nothing interrupts a running task
Shows that a queued callback cannot be squeezed into the middle of another callback, no matter how long that callback takes.
setTimeout(() => {
console.log('A start');
setTimeout(() => console.log('C'), 0);
console.log('A end');
}, 0);
setTimeout(() => {
console.log('B start');
console.log('B end');
}, 0);Example explained
Line 1Both outer timers are registered during the same synchronous pass, so the loop picks them up in registration order.
Line 2'A start' and 'A end' print with nothing between them because B cannot be pushed onto the stack while A's frame is still on it.
Line 3The inner setTimeout only adds C to the queue; registering is instant, running is not.
Line 4C reaches the queue after B is already waiting there, so C runs last.
Handing the thread back in chunks
Splits a loop into small pieces that return, letting unrelated queued work run in between.
let processed = 0;
function processChunk(items, from) {
const to = Math.min(from + 2, items.length);
for (let i = from; i < to; i++) processed++;
if (to < items.length) {
setTimeout(() => processChunk(items, to), 0);
} else {
console.log('done, processed', processed);
}
}
processChunk([1, 2, 3, 4, 5], 0);
console.log('main script keeps going');
setTimeout(() => console.log('unrelated work got a turn'), 0);Example explained
Line 1processChunk handles two items and then returns, which empties the stack and lets the loop continue.
Line 2The setTimeout inside it is the exact point where the thread is handed back.
Line 3'unrelated work got a turn' lands between chunks because that timer was queued before the third chunk was scheduled.
Line 4processed still ends at 5: yielding changes when the work happens, not how much work there is.
Important notes
The single-stack, run-to-completion rule is identical in Node, but the host differs: Node's libuv thread pool handles file and DNS work off-thread, while a browser also fits rendering steps between tasks.
Web Workers give real parallelism, but each has its own stack and its own event loop and communicates through copied messages, so sharing mutable state is not an option short of SharedArrayBuffer.
Common mistakes
Reading setTimeout(fn, 0) as 'run fn now': fn cannot interrupt the function that scheduled it, so its output appears after lines written below the call, which looks like a broken timer.
Using while (Date.now() < end) {} as a sleep: it holds the only thread, so timers, promise callbacks, painting and clicks are all stalled until the loop exits and the tab looks hung.
Wrapping a slow JSON.parse or a huge for loop in setTimeout to 'make it async': the work still runs on the same thread and still freezes the page, only slightly later.
Try it yourself
Change, predict, then run
In a browser editor, add one button whose click handler logs Date.now() and a second button whose handler busy-waits three seconds with while (Date.now() < end) {}. Click the busy button, click the logging button four times during the freeze, and check that all four logs appear together afterwards with nearly identical timestamps.
Open the JavaScript workspaceCheck your understanding
A click handler starts a synchronous loop that runs for two seconds. During those two seconds the user clicks the same button three more times. What happens?
- The loop pauses so each click handler can run, then resumes where it left off.
- The browser runs each click handler on a separate thread, so they overlap with the loop.
- The clicks wait as queued tasks and the handler runs three more times, back to back, after the loop returns.
- The clicks are dropped, because a page can only hold one pending event at a time.
Show answer
There is no preemption: the running handler owns the single stack until it returns, and only then can the loop dispatch the buffered click tasks, so the handler runs three more times in a row. Dropping the clicks is tempting because the page looks dead, but the browser buffers discrete events like click and delivers them once the thread is free; only continuous streams such as mousemove get coalesced, which is a different mechanism.