The JavaScript Event Loop Explained: How Microtasks and Macrotasks Are Ordered
Why does Promise.then run before setTimeout(0)? A walkthrough of the call stack, task queue, and microtask queue with runnable examples, plus how browsers and Node.js differ and how to keep long work from freezing the UI.
Why does this print 1 → 4 → 3 → 2?
Let's start with a quiz.
console.log("1");
setTimeout(() => console.log("2"), 0);
Promise.resolve().then(() => console.log("3"));
console.log("4");
The setTimeout delay is 0, so you might expect it to run right away. The actual output is:
1
4
3
2
Promise.then runs before setTimeout(0). To understand why, you need to know how the event loop works.
What is the event loop?
The event loop is what lets single-threaded JavaScript handle asynchronous work: it decides the order between "code to run now" and "callbacks to run later."
A JavaScript engine runs one piece of code at a time. It only looks like it's juggling timers, network requests, and clicks at once because the waiting is handed off to the browser (or Node.js), and the callbacks of finished work are lined up in queues and run one by one.
These are the pieces involved:
| Piece | Role | Examples |
|---|---|---|
| Call stack | Where currently running functions pile up | Regular function calls |
| Web APIs / runtime APIs | Handle slow work outside the engine | Timers, fetch, DOM events |
| Task queue (macrotasks) | Where callbacks of finished async work wait | setTimeout, setInterval, event handlers |
| Microtask queue | Where higher-priority callbacks wait | Promise.then/catch/finally, code after await, queueMicrotask |
The core ordering rule
The event loop repeats this cycle forever:
- Take one task from the task queue and run it (at first, the whole script is one task).
- When the call stack is empty, run microtasks until the microtask queue is completely empty.
- Repaint the screen if needed (rendering). requestAnimationFrame callbacks run in this step too.
- Go back to step 1.
Step 2 is the key.
Tasks run one at a time. Microtasks run all at once, until the queue is empty.
Now replay the quiz:
- Script runs (a task) → prints 1 → the setTimeout callback goes to the task queue → the then callback goes to the microtask queue → prints 4
- Script ends, call stack is empty → drain the microtask queue → prints 3
- Next task → prints 2
Practice with examples
1. Microtasks created inside a task
setTimeout(() => {
console.log("timeout 1");
Promise.resolve().then(() => console.log("micro in timeout 1"));
}, 0);
setTimeout(() => console.log("timeout 2"), 0);
timeout 1
micro in timeout 1
timeout 2
Even though both timers are already queued, the microtask created inside the first task runs right after that task finishes. Only then does the loop move on to the second task.
2. async / await
Code after await is equivalent to a then callback, which means it is scheduled as a microtask.
async function foo() {
console.log("foo start");
await null;
console.log("foo end");
}
console.log("script start");
foo();
console.log("script end");
script start
foo start
script end
foo end
An async function runs synchronously until its first await. That's why foo start prints before script end. It's easy to assume an async function is asynchronous from the very first line, but it isn't.
Browser vs Node.js
Node.js also uses an event loop, but it's built on libuv, splits the loop into more phases, and has APIs browsers don't.
| Browser | Node.js | |
|---|---|---|
| Rendering step | Yes (requestAnimationFrame) | No |
| Extra queue | - | process.nextTick queue (runs before Promises) |
| Extra API | - | setImmediate (runs in the check phase) |
// Node.js, CommonJS module
setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));
process.nextTick(() => console.log("nextTick"));
Promise.resolve().then(() => console.log("promise"));
nextTick
promise
timeout / immediate (their order can change from run to run)
In the main module, the order of setTimeout(0) and setImmediate is not guaranteed. Inside an I/O callback (for example, the callback of fs.readFile), setImmediate always runs first. And in ES modules (.mjs), top-level code already runs inside a microtask, so promise can print before nextTick.
Problems you'll hit in real code
1. Heavy synchronous work freezes the screen
Rendering only happens between tasks. If one task takes a long time, clicks, scrolling, and screen updates all freeze until it's done.
// ❌ Processing 100,000 items in one go freezes the UI
items.forEach(processItem);
Split the work into chunks and create task boundaries along the way so the browser can breathe. Briefly handing the main thread back like this is called yielding.
// ✅ Hand off to a new task every so often so rendering and input can run
const yieldToMain = () => new Promise((resolve) => setTimeout(resolve, 0));
async function processAll(items) {
for (let i = 0; i < items.length; i++) {
processItem(items[i]);
if (i % 500 === 0) await yieldToMain();
}
}
Recent Chromium-based browsers provide scheduler.yield() for exactly this. Check for support and use it where available.
2. Microtasks don't yield
If you swap setTimeout for Promise.resolve() in the code above, it stops working. Microtasks keep running until the queue is empty, so rendering never gets a chance in between. If microtasks keep scheduling new microtasks, the page freezes completely, just like while (true).
// ❌ Rendering never happens
function loop() {
Promise.resolve().then(loop);
}
loop();
3. "0ms" doesn't mean "now"
setTimeout(fn, 0) means "put this in the task queue after at least 0ms." It only runs after every task and microtask ahead of it is done, and browsers may raise the minimum delay of nested timers to 4ms. Don't rely on it for precise timing.
Summary: why this is worth knowing
| What you see | The event loop explanation |
|---|---|
| A then callback runs before setTimeout(0) | Microtasks run before the next task |
| The top of an async function runs immediately | It's synchronous until the first await |
| The screen freezes during a big loop | One task is blocking the chance to render |
| Still frozen after splitting with Promise | All microtasks run before rendering |
Once you understand the event loop, you spend far less time on "why are these logs in this order?", and you can quickly pin down why a loading spinner won't spin or input feels laggy. When async code doesn't behave the way you expect, start by asking: "Is this callback a task or a microtask?"