JavaScript Event Loop Explained: Call Stack, Microtasks and Timers With Examples
Why does a setTimeout of zero still run last? How can one thread handle so much at once? Learn how the event loop really works, test it with code we ran, and solve the classic interview question.
- JavaScript runs your code on a single thread, one piece at a time.
- When the call stack is empty, all microtasks run first, and then one task.
setTimeout(fn, 0)means “as soon as possible after a minimum delay”, not “now”.- Long synchronous work blocks everything, so split it up or move it to a worker.
Run a few lines with setTimeout and a promise, and the output order may surprise you. The JavaScript event loop explains why. It is the mechanism that lets a single-threaded language handle timers, network calls and clicks without freezing.
Once you understand it, a whole class of confusing bugs starts to make sense. We ran each example on Node.js 24, and the output comes from that run. The core ordering rules also apply in browsers, and we point out where Node.js differs. You will get more from this if you already know the basics of JavaScript promises.
Why the event loop exists
JavaScript executes your code on one main thread, so only one piece of it runs at a time. If it simply waited for a network request, a page would freeze until the response came back.
So JavaScript does not wait. Slow work is handed to the host environment, meaning the browser or Node.js, which can handle it in the background. When that work finishes, a callback is placed in a queue. The event loop’s job is to move queued callbacks onto the stack when it is free.
Think of a chef working alone. The chef puts a dish in the oven and sets a timer, rather than staring at it. When the timer rings, the task goes on a list, and the chef picks it up after finishing what is currently in hand.
The moving parts
| Part | What it does | Examples |
|---|---|---|
| Call stack | Tracks the functions currently running | Your synchronous code |
| Host APIs | Do work outside your code | Timers, fetch, file reads |
| Task queue | Holds callbacks that are ready to run | setTimeout, clicks, I/O |
| Microtask queue | Holds higher-priority callbacks | .then, await, queueMicrotask |
| Event loop | Decides what runs next | Runs continuously |
Tasks are sometimes called macrotasks. Strictly speaking, there may be several task sources, and the exact details differ between environments.
The rules
- Run all synchronous code until the call stack is empty.
- Run all microtasks, including any new ones added along the way.
- In a browser, render an update if one is due.
- Take one task from a queue and run it.
- Go back to step 2.
The most important detail: all microtasks, then one task. Most event loop puzzles are solved by applying that single rule.
See the order
console.log("1 sync");
setTimeout(() => console.log("5 timeout"), 0);
Promise.resolve().then(() => console.log("3 promise"));
queueMicrotask(() => console.log("4 microtask"));
console.log("2 sync");
// 1 sync
// 2 sync
// 3 promise
// 4 microtask
// 5 timeout
Here is what happened. The two console.log lines are synchronous, so they ran first. The promise callback and queueMicrotask went into the microtask queue, and they ran as soon as the stack was empty, in the order they were added. The timer callback had to wait for a task turn, even though its delay was zero.
Microtasks run between timers
After every single task, the microtask queue is emptied again. This example proves it:
setTimeout(() => {
console.log("timer 1");
Promise.resolve().then(() => console.log("promise inside timer 1"));
}, 0);
setTimeout(() => console.log("timer 2"), 0);
// timer 1
// promise inside timer 1
// timer 2
The promise, which was created during the first timer, ran before the second timer. Modern browsers and Node.js 11 and later behave this way.
Where await fits
await splits a function in two. Everything before it runs synchronously, and everything after it is scheduled as a microtask. This surprises many people:
async function task() {
console.log("A before await");
await null;
console.log("C after await");
}
task();
console.log("B after calling task");
// A before await
// B after calling task
// C after await
An async function does not start in the background. It runs on the same thread until it reaches its first await. That even applies when awaiting something that is not a real promise, like null. See our JavaScript async/await guide for more on this syntax.
Blocking the loop
While synchronous code runs, nothing else can. To show this, we set a timer for 0 ms, then kept the thread busy for half a second:
const start = Date.now();
setTimeout(() => console.log("timer ran after", Date.now() - start, "ms"), 0);
while (Date.now() - start < 500) {}
// timer ran after about 500 ms
The timer’s delay is a minimum, not a guarantee. It ran only when the stack became free. On a web page, a long loop like this means that clicks do not respond and animations stop. On a server, every other request has to wait, which is why one slow synchronous call can hurt all users of a Node.js process.
Common causes of blocking include huge loops, parsing very large JSON, synchronous file functions like readFileSync in request handlers, and catastrophic regular expressions.
Tip: In Chrome DevTools, the Performance panel marks tasks longer than 50 ms as long tasks. It is a quick way to find what is making a page feel sluggish.
Staying responsive
For heavy work, give the event loop a chance to breathe. One way is to pause on a timer now and then, which lets other tasks run:
const items = [0, 1, 2, 3, 4];
async function processAll() {
for (const item of items) {
if (item === 2) await new Promise((r) => setTimeout(r, 0));
console.log("item", item);
}
}
setTimeout(() => console.log("other work gets a turn"), 0);
processAll();
// item 0
// item 1
// other work gets a turn
// item 2
// item 3
// item 4
The other timer ran in the middle, because we yielded with a real task. Real code would yield every so many items or milliseconds. For truly CPU-heavy work, such as image processing, use a Web Worker in browsers or a worker thread in Node.js, which run on separate threads.
Microtask starvation
Since the loop empties the microtask queue completely before moving on, a microtask that keeps scheduling more can stop everything else. We tested this with 100,000 chained microtasks:
let count = 0;
let timerFired = false;
setTimeout(() => { timerFired = true; }, 0);
function spin() {
count++;
if (count < 100000) queueMicrotask(spin);
else console.log("timer fired during microtasks?", timerFired);
}
queueMicrotask(spin);
// timer fired during microtasks? false
The timer could not run until all of them were done. If that chain never ended, timers would never fire, and in a browser the page would never repaint. Note that yielding with await on a resolved promise does not help here, because it just adds another microtask. You need a real task, such as a timer, to give others a turn.
Rendering in browsers
A browser usually gets its chance to paint between tasks. That explains a common bug:
spinner.hidden = false;
doHeavyWork(); // blocks the thread
spinner.hidden = true;
The spinner never appears, because the browser had no opportunity to render between those lines. To fix it, let a frame pass before the heavy work begins, or move that work to a worker. For code that changes visuals, requestAnimationFrame runs just before the next paint, which makes it the right tool for animation.
Node.js differences
Node.js has its own event loop with several phases, including timers, I/O polling and setImmediate. It also has process.nextTick, which runs in its own queue. Here is what we saw when running these from inside an async function:
setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));
process.nextTick(() => console.log("nextTick"));
Promise.resolve().then(() => console.log("promise"));
// promise
// nextTick
// immediate
// timeout
Many articles state that nextTick always beats promises, and that a timer beats setImmediate. Our result shows that it is not that simple. Our code was already running within a microtask, so the promise queue carried on first. And the order of setTimeout and setImmediate is known to vary depending on where they are called from. So do not write code that depends on the exact order of these. If one thing must happen after another, chain them explicitly.
Interview quiz
Try to work out the order before you read the answer:
console.log("a");
setTimeout(() => console.log("b"), 0);
Promise.resolve().then(() => {
console.log("c");
setTimeout(() => console.log("d"), 0);
Promise.resolve().then(() => console.log("e"));
});
console.log("f");
Our run printed a, f, c, e, b, d. Here is why:
aandfare synchronous, so they run first.- The stack is empty, so microtasks run:
c, then the new microtaske, in the same round. - Now one task: timer
b, which was queued first. - Then the next task: timer
d, which was queued during the microtask.
Common mistakes
- Thinking
setTimeoutis exact. The delay is only a minimum. - Thinking
asyncmeans another thread. Async code still runs on the main thread between awaits. - Using sleeps to fix ordering. Waiting on the actual condition is far more reliable, which also applies to flaky tests.
- Using
forEachwithawait. It does not wait between items. See JavaScript array methods. - Doing heavy synchronous work in a server handler. One request can delay every other request.
Final thoughts
Remember the core rule: run the stack until it is empty, run all microtasks, then run one task, and repeat. Everything else follows from it. To make it stick, write your own version of the quiz above, predict the output on paper, and then run it. Getting one wrong and understanding why will teach you more than any diagram.
Frequently asked questions
What is the event loop in JavaScript?
It is the mechanism that runs queued callbacks when the call stack is empty. It lets single-threaded JavaScript handle timers, input and network responses without blocking.
What is the difference between a microtask and a task?
Microtasks, such as promise callbacks, all run as soon as the stack empties. Tasks, such as timers, run one at a time, with microtasks drained after each.
Why does setTimeout with 0 not run immediately?
Its callback must wait for a task turn. All synchronous code and microtasks run first, and the delay is a minimum rather than an exact time.
Is JavaScript single-threaded?
Your main JavaScript code runs on one thread. Browsers and Node.js use other threads for some background work, and workers let you run code in parallel.
Does await block the event loop?
No. await pauses only that async function, and other code can run in the meantime. Long synchronous code before or after it can still block the loop, though.
Keep learning
Need help with testing, WordPress or SEO?
We help teams ship better software and grow their sites. Tell us what you are working on and we will suggest a clear next step.