How the JavaScript Event Loop Actually Works
JavaScript runs on a single thread, yet it juggles timers, network requests, and user clicks without (usually) freezing. The event loop is the reason why. This guide walks through every moving part — the Call Stack, Web APIs, the Microtask Queue, the Callback Queue — from first principles up through the details that trip up experienced developers. Use the visualizer above to run any example live while you read.
The big picture
JavaScript engines run your code on one call stack. There's no thread pool for your functions, no parallel execution of two lines of JS at once. So how does a web page fetch data, run a 3-second timer, and still respond to a click in the meantime?
The answer: JavaScript doesn't actually do the waiting itself. When you call setTimeout, fetch, or read a file in Node, the JS engine hands that work off to something outside itself — the browser's Web APIs (or Node's C++ APIs, backed by libuv) — and immediately moves on to the next line. When that background work finishes, it doesn't jump back into your running code (that would be true parallelism and would corrupt everything). Instead, it drops a message — a callback — into a queue and waits.
The event loop is a simple, tireless referee whose only job is: "Is the call stack empty? If so, is there a queued callback ready to run? If so, put it on the stack." That's it. Everything asynchronous in JavaScript — promises, timers, events, I/O — ultimately reduces to functions waiting in a queue for this referee to notice.
The Call Stack
The call stack is a simple LIFO (last-in, first-out) structure that tracks which function is currently executing and who called it. Every time a function is invoked, a new frame is pushed on top. When that function returns, its frame is popped off.
function multiply(a, b) { return a * b; }
function square(n) { return multiply(n, n); }
function printSquare(n) { console.log(square(n)); }
printSquare(5);
// Stack grows: printSquare → square → multiply
// Stack shrinks: multiply returns → square returns → printSquare returns
Nothing else can happen while there's anything on the call stack — no timer fires, no promise callback runs, no click handler executes, no repaint occurs. This is why a long synchronous loop freezes the page: the stack never empties, so the event loop never gets a turn.
Web APIs (and Node APIs)
Functions like setTimeout, setInterval, fetch, DOM event listeners, and geolocation aren't actually part of the JavaScript language — they're provided by the host environment (the browser, or Node's runtime). When you call one, the engine registers the request with the host and returns control to your code immediately; your call stack frame for setTimeout(...) pops right away, long before the timer has actually elapsed.
The host does the actual waiting — counting down a timer, listening on a socket, waiting for a file read — completely outside the JS thread. Once the work is done, the host doesn't run your callback directly either; it places it into the appropriate queue for the event loop to pick up later.
The Callback Queue (macrotasks)
Also called the task queue or macrotask queue. This is where callbacks from setTimeout, setInterval, DOM events, and I/O completions land once their Web API work is finished. The event loop takes one task from here per loop iteration — never more — runs it to completion (which may itself push new things onto the stack, schedule new microtasks, or register new Web API work), and then checks the microtask queue again before touching this queue a second time.
Because only one macrotask runs per iteration, and each is followed by a full microtask drain, macrotasks are the "slow lane" — good for callbacks that can tolerate being deferred, and a natural point where the browser can sneak in a repaint.
The Microtask Queue
This is where .then()/.catch()/.finally() callbacks, await continuations, and queueMicrotask() callbacks land. The critical rule: the microtask queue is always drained completely before the event loop moves on — including any new microtasks scheduled by microtasks that are currently running. If a microtask schedules another microtask, that one runs too, before any macrotask gets a turn.
Promise.resolve().then(() => {
console.log('first');
Promise.resolve().then(() => console.log('spawned by first'));
});
Promise.resolve().then(() => console.log('second'));
// Output: first, spawned by first, second
// — the microtask spawned by "first" still runs before "second"'s own
// turn was already queued, but strictly, all pending microtasks drain
// in FIFO order, and freshly-added ones join the same queue.
This is why microtasks are the "fast lane": a chain of promises can, in principle, keep the event loop from ever reaching a macrotask (this is a real bug pattern — see Common mistakes).
The event loop algorithm, step by step
Put together, one full pass looks like this:
- Run all synchronous code from top to bottom (the initial script is itself treated like a task).
- Call stack is now empty. Drain the microtask queue completely — run every pending microtask, including ones added while draining — until it's empty.
- If the browser has scheduling headroom, it may perform a rendering update here (see Where rendering fits in).
- Take the single oldest task from the callback queue (if any) and run it on the call stack.
- Go back to step 2.
This loop runs forever, as fast as it can when there's work, and idles when both queues and all pending Web API work are empty.
Reading the visualizer's steps
When you click Run & Trace above, your code is executed once instantly against a virtual scheduler, producing a full timeline of discrete steps. Stepping through with the transport controls replays that timeline. Here's what each kind of step means:
| Step | What happened |
|---|---|
| Call Stack push | A function call just started and was placed on top of the Call Stack. It runs before anything below it can continue. |
| Call Stack pop | A function finished (hit return or ran out of statements) and was removed from the stack. Control returns to whoever called it. |
| Web APIs — added | An async operation (setTimeout, setInterval, fetch) was handed off to run outside the call stack. |
| Callback Queue — added | A Web API finished its background work and moved its callback into the Callback Queue, where it waits its turn. |
| Callback Queue — running | The Call Stack and Microtask Queue were both empty, so the event loop pulled the oldest callback here and started running it. |
| Microtask Queue — added | A Promise settled (or queueMicrotask was called), scheduling a reaction callback. |
| Microtask Queue — running | The event loop pulled the next microtask and ran it. This always happens before the next Callback Queue task. |
The current line of your code is highlighted in the editor at each step where that's meaningful (function calls, scheduling calls, console output), so you can follow along token-by-token.
async/await, demystified
async/await is syntax sugar over promises — it doesn't introduce a new concurrency model. An async function always returns a promise. Inside it, await expr does three things: it evaluates expr, wraps the result in a promise if it isn't already one, suspends the function and immediately returns control to the caller, and schedules the rest of the function to resume as a microtask once that promise settles.
async function run() {
console.log('A');
await null; // suspends here — control returns to the caller now
console.log('B'); // resumes later, as a microtask
}
console.log('start');
run();
console.log('end');
// Output: start, A, end, B
Notice that calling run() does not block — the synchronous part of the function (up to the first await) runs immediately, and everything after resumes later. This is exactly what you'd see if you rewrote it with raw .then() chains; async/await just reads top-to-bottom instead of nesting callbacks.
Where rendering fits in (browsers only)
Browsers try to repaint the screen around 60 times per second, but a repaint can only happen when the call stack is empty — the same rule that governs everything else. In practice, the browser looks for an opportunity between macrotasks (roughly: after a task and its resulting microtasks are done) to run style/layout/paint if a frame is due. requestAnimationFrame callbacks are scheduled to run right before that paint step, making them the right tool for visual updates — unlike setTimeout, which has no awareness of the frame schedule at all.
Node.js vs. the browser
Node.js has its own event loop, implemented by libuv, and it's more elaborate than the browser's. Instead of one generic callback queue, Node cycles through named phases each iteration: timers (setTimeout/setInterval), pending callbacks, poll (I/O), check (setImmediate), and close callbacks. Microtasks (promises) and, uniquely to Node, process.nextTick callbacks are drained between every phase — and process.nextTick jumps the queue ahead of even regular promise microtasks. The high-level rule from this guide still holds — synchronous code first, then microtasks fully drained, then one macrotask-ish thing at a time — but the exact phase ordering is Node-specific and worth a dedicated read if you're debugging server-side timing bugs.
Common mistakes & gotchas
Blocking the main thread
A tight synchronous loop or heavy computation blocks everything — timers, promise callbacks, clicks, scrolling, painting — because none of it can run until the call stack empties. There's no such thing as a JS engine "interrupting" your running function to squeeze in a timer.
Microtask starvation
Because the microtask queue must fully drain before the next macrotask, a promise reaction that keeps re-scheduling itself (e.g. an unbounded .then() recursion) can starve macrotasks — and rendering — indefinitely, even though each individual microtask finishes quickly.
Assuming setTimeout(fn, 0) means "right now"
It means "as soon as possible, but strictly after the current synchronous code and every pending microtask." If you schedule a promise and a zero-delay timeout back to back, the promise callback always wins.
Off-by-one thinking about await
Every await, even await on an already-resolved value, defers the rest of the function by at least one microtask turn. Code after await somePromise never runs synchronously with the code that called the async function, even if the promise was already settled.
Forgetting timers keep objects alive
An active setInterval (or an unresolved promise chain referencing a closure) keeps everything that closure captured reachable, which is a common source of memory leaks in long-running pages or servers.
Glossary
- Call Stack
- The single, LIFO structure tracking which function is currently running and its chain of callers.
- Heap
- The region of memory where objects, arrays, and functions are actually allocated; the stack holds references into it.
- Web API
- Functionality provided by the browser (or Node runtime) outside the JS language itself — timers, network, DOM events, file I/O.
- Task / Macrotask
- A unit of work queued in the Callback Queue — one runs per event loop iteration.
- Microtask
- A higher-priority queued callback (promise reactions,
queueMicrotask) that fully drains before the next macrotask. - Event Loop
- The process that repeatedly checks whether the call stack is empty and, if so, moves the next queued callback onto it.
- Starvation
- When one queue (typically microtasks) keeps generating more work for itself, indefinitely delaying another queue's turn.
- Blocking
- Running synchronous code long enough that the call stack can't empty, freezing all async work and rendering.
Frequently asked questions
What is the JavaScript event loop?
It's the mechanism that lets single-threaded JavaScript handle asynchronous work: it continuously checks whether the call stack is empty, and when it is, moves queued callbacks — all pending microtasks first, then a single macrotask — onto the stack to run.
Do microtasks or macrotasks run first?
Microtasks always run first, and completely — every pending promise reaction and queueMicrotask callback, including new ones scheduled while draining — before the event loop is allowed to start the next macrotask.
Is setTimeout(fn, 0) really immediate?
No. It still waits for the current call stack to finish and for the microtask queue to fully drain, so it always runs after synchronous code and any pending promises — even ones scheduled after it.
How does async/await relate to the event loop?
It's syntax sugar over promises. Each await pauses the function and immediately returns control to the caller with a pending promise; when the awaited value settles, the rest of the function resumes as a microtask.
Why can a JavaScript app freeze even though it's "asynchronous"?
Because there's only one call stack. Long-running synchronous code blocks it completely, so no timers, promise callbacks, UI events, or rendering can happen until it finishes.