The event loop
The call stack, task and microtask queues, and why setTimeout(fn, 0) is not instant.
JavaScript runs your code on a single thread — it can only do one thing at a time. Yet web pages fetch data, run timers and respond to clicks all at once, and Node servers handle thousands of connections. The trick is the event loop. Understanding it explains why setTimeout(fn, 0) isn't instant, why promises run "first", and why a slow loop freezes the page.
The call stack#
The call stack tracks which function is running. Calling a function pushes it on; returning pops it off:
While running, the stack grows: printSquare → square → multiply, then unwinds as each returns. When you see a stack trace in an error, you're looking at this stack. Infinite recursion fills it up: RangeError: Maximum call stack size exceeded.
Synchronous code runs to completion on the stack. Nothing else can happen until it finishes.
Where asynchronous work happens#
Timers, network requests, file reads and user events aren't done by the JavaScript engine itself. They're provided by the host environment — the browser (Web APIs) or Node.js (libuv). You hand them a callback, they do the waiting elsewhere, and when the result is ready they queue your callback.
Even with a 0 ms delay, the timer callback can't run until the current code has finished and the stack is empty.
The event loop#
The event loop is a simple, endless cycle:
- Run the current task (e.g. your script, a timer callback, an event handler) until the call stack is empty.
- Run all queued microtasks (promise callbacks,
queueMicrotask), including any microtasks they add. - (Browser) Render the page if needed — update layout and paint.
- Take the next task from the task queue and go back to step 1.
There are two main queues:
Microtasks run before the next task#
Step by step:
- The script is the current task: it logs
script startandscript end, schedules a timer (task) and two microtasks. - The stack is empty, so all microtasks run:
promise 1, thenmicrotask. Runningpromise 1queuedpromise 2as a new microtask, which also runs now. - Only then does the event loop pick the next task: the timer, logging
timeout.
async/await follows the same rules
Code after an await is a microtask continuation:
An async function runs synchronously until its first await, then returns to the caller. The rest resumes as a microtask.
Blocking the main thread#
Because everything shares one thread, a long synchronous task blocks timers, rendering and clicks:
The timer was due after 10 ms but had to wait for the loop. In a browser, the page would also be frozen for that half-second — no scrolling, no clicks, no animations. That's what "jank" is.
Keeping the page responsive
- Break up big jobs into chunks and yield between them so the browser can render and handle input:
Modern Chromium browsers also offer scheduler.yield() for exactly this.
- Move heavy computation to a Web Worker (browser) or a worker thread (Node). Workers run on separate threads and communicate with messages.
- Don't flood the microtask queue — an endless chain of microtasks starves rendering just like a busy loop, because the browser only renders after the microtask queue is empty.
Timers in detail#
- The delay is a minimum, not a guarantee.
- Browsers clamp nested timers to at least ~4 ms and throttle timers in background tabs to save battery.
- For animations, use
requestAnimationFrame(callback), which runs just before the browser paints the next frame.
Node.js specifics#
Node's event loop has a few extra phases (timers, I/O polling, setImmediate "check" phase…) and two extra microtask-like queues:
process.nextTick callbacks run even before promise microtasks. The relative order of timeout and immediate from the main script can vary between runs; inside an I/O callback, setImmediate always runs first. Day to day, you rarely need these details — use promises and async/await.
Why this design?#
A single thread with an event loop means you never have two pieces of JavaScript modifying the same variable at the same instant — no locks, no data races. The cost: you must never block the thread for long, and you need patterns to deal with results that arrive later. The next three lessons cover those patterns — callbacks, promises and async/await.
Common mistakes#
- Expecting
setTimeout(fn, 0)to run before promise callbacks. - Reading a value right after starting an async operation and getting
undefined, because the result hasn't arrived yet. - Running a heavy loop on the main thread and wondering why the UI freezes.
- Using
setIntervalfor work that can take longer than the interval — calls pile up. Prefer asetTimeoutthat re-schedules itself after each run.
What's next#
Next: callbacks — the original way of handling asynchronous results, and why we moved on to promises.
Check your understanding
Quick quiz
1.What is the output order of:
console.log("A"); setTimeout(() => console.log("B"), 0); Promise.resolve().then(() => console.log("C")); console.log("D");2.Why does a long-running loop make a web page freeze?
3.What does
setTimeout(fn, 1000)guarantee?
Finished reading?
Mark this lesson complete to track your progress.