JavaScript runs your code on one thread, one piece at a time. The event loop is the mechanism that decides which piece runs next. Once you understand it, you can predict exactly when a then callback, an await or a setTimeout runs, and you can explain why a busy loop freezes the page.
The three parts
Three structures matter:
- The call stack. The functions that are running right now. When a function is called it is pushed on; when it returns it is popped off. JavaScript only does one thing at a time because there is only one stack.
- The task queue. Work waiting to start from an empty stack: a
setTimeoutcallback, a click handler, a message from a worker, a network response. Browsers actually keep several task queues and choose between them, but you can think of "tasks" as one group. - The microtask queue. Small follow-up jobs that must run as soon as the current code finishes: promise reactions (
then,catch,finally), the code after anawait, and callbacks passed toqueueMicrotask().
The rule
The event loop repeats this cycle:
- Take one task from a task queue and run it until the call stack is empty.
- Run every microtask in the microtask queue, including any new microtasks queued while doing so, until the queue is empty.
- If it is time for a new frame, the browser may update the rendering (this is when
requestAnimationFramecallbacks, style, layout and paint happen). - Go back to step 1.
Running your script for the first time counts as a task. So the practical rule for reading code is:
- All synchronous code runs first, top to bottom.
- Then all microtasks run.
- Then the next task runs, followed by its microtasks, and so on.
First example
console.log('1: script start');
setTimeout(() => {
console.log('5: timeout');
}, 0);
Promise.resolve().then(() => {
console.log('3: promise reaction');
});
queueMicrotask(() => {
console.log('4: queueMicrotask');
});
console.log('2: script end');
// → 1: script start
// → 2: script end
// → 3: promise reaction
// → 4: queueMicrotask
// → 5: timeout
Step by step:
console.log('1: ...')runs immediately.setTimeout(..., 0)hands the callback to the timer system. When the timer expires, the callback is put in the task queue. It cannot run until the current task (the whole script) is finished.Promise.resolve()is already fulfilled, sothenqueues its callback as a microtask straight away.queueMicrotaskqueues a second microtask, behind the first.console.log('2: ...')runs. The script ends and the stack is empty.- The microtask queue is drained in order: 3, then 4.
- Only now can the next task, the timeout, run.
A delay of 0 does not mean "now". It means "as a new task, after everything already queued ahead of it, and after all microtasks". Browsers may also clamp very short nested timers to a minimum of a few milliseconds.
await is a microtask too
An async function runs synchronously until its first await. Everything after the await is resumed later as a microtask.
async function loadSettings() {
console.log('B: inside loadSettings, before await');
await null;
console.log('D: after await');
}
console.log('A: before call');
loadSettings();
console.log('C: after call');
// → A: before call
// → B: inside loadSettings, before await
// → C: after call
// → D: after await
Calling loadSettings() runs line B at once, then pauses at await and returns a pending promise to the caller. The caller carries on with line C. When the script finishes, the microtask resumes the function and D is printed. Awaiting a value that is not a promise, such as null, still pauses until the next microtask checkpoint.
Chained then callbacks interleave
Each then callback queues the next one in its chain only when it finishes. Two independent chains therefore take turns:
Promise.resolve()
.then(() => console.log('a1'))
.then(() => console.log('a2'));
Promise.resolve()
.then(() => console.log('b1'))
.then(() => console.log('b2'));
// → a1
// → b1
// → a2
// → b2
After the script, the microtask queue holds a1 and b1. Running a1 fulfils the promise returned by the first then, which queues a2 at the back of the queue, behind b1. Running b1 queues b2. Then a2 and b2 run.
You rarely need to rely on this interleaving in real code, and you should not. It is useful because it proves the rule: microtasks run in the order they were queued, one at a time.
Microtasks inside a task
Every task gets its own microtask checkpoint when it finishes. A promise created inside a timeout runs before the next timeout:
setTimeout(() => {
console.log('timeout 1');
Promise.resolve().then(() => console.log('microtask from timeout 1'));
}, 0);
setTimeout(() => {
console.log('timeout 2');
}, 0);
// → timeout 1
// → microtask from timeout 1
// → timeout 2
Both timers are queued as separate tasks. The first task logs, queues a microtask and ends. Before the event loop picks up the second task, it drains the microtask queue. This order is the same in current browsers and in Node.js 20 and later.
Exercise: predict the order
Cover the answer and write down the output before reading on.
console.log('start');
setTimeout(() => console.log('timeout'), 0);
async function work() {
console.log('work: begin');
await Promise.resolve();
console.log('work: resumed');
}
Promise.resolve().then(() => console.log('then'));
work();
console.log('end');
Reasoning:
- Synchronous:
start. The timeout is scheduled as a task. Thethencallback is queued as microtask 1.work()runs synchronously and logswork: begin, then awaits an already fulfilled promise, which queues its continuation as microtask 2. Thenend. - Microtasks in order:
then, thenwork: resumed. - Next task:
timeout.
start
work: begin
end
then
work: resumed
timeout
When you are unsure, label each callback "sync", "microtask" or "task" before predicting anything. Most ordering puzzles become simple once every line has a label.
Why long tasks freeze the page
Rendering and input handling only happen between tasks. While one task runs, the browser cannot paint, cannot respond to a click and cannot run any other callback.
<button type="button" id="crunch">Run heavy work</button>
<p id="status" role="status" aria-live="polite"></p>
<script type="module">
const button = document.querySelector('#crunch');
const status = document.querySelector('#status');
button.addEventListener('click', () => {
status.textContent = 'Working…';
const end = Date.now() + 3000;
while (Date.now() < end) {
// Busy for three seconds. Nothing else can happen.
}
status.textContent = 'Done.';
});
</script>
You never see "Working…". Both text changes happen inside the same task, and the browser only paints after the task ends, by which point the text already says "Done.". For three seconds the page ignores every click and key press.
Microtasks make this worse, not better. Because the loop drains the entire microtask queue before rendering, a microtask that keeps queuing another microtask blocks the page as completely as a while loop:
function forever() {
queueMicrotask(forever); // Never do this: rendering never gets a turn.
}
Breaking up long work
Split a long job into chunks and give the event loop a turn between them by scheduling each chunk as a new task:
function yieldToEventLoop() {
return new Promise((resolve) => setTimeout(resolve, 0));
}
async function processAll(items, handleItem) {
let lastYield = performance.now();
for (const item of items) {
handleItem(item);
if (performance.now() - lastYield > 50) {
await yieldToEventLoop();
lastYield = performance.now();
}
}
}
await yieldToEventLoop() resolves from a setTimeout, which is a task, so rendering and input can happen before the loop carries on. Awaiting Promise.resolve() would not help: that is only a microtask, and the browser would still not render in between.
The 50 millisecond threshold matches what browser tools treat as a "long task". Some browsers also offer scheduler.yield() for this purpose. It is not yet available everywhere, so feature-detect it and fall back to the setTimeout version. For truly heavy computation, move the work off the main thread into a Web Worker.
Node.js
Node.js has its own event loop, built on the libuv library, with several phases for timers, I/O and setImmediate. Promise microtasks follow the same rule as in the browser: they all run before Node moves on to the next callback. Node also has process.nextTick(), a queue that is processed before promise microtasks in most cases. The details of Node's phases belong to a Node.js course; for code that runs in both environments, the sync, microtask, task rule in this lesson is what you need.
Summary
- One call stack means one piece of JavaScript runs at a time.
- Tasks include timers, events and network callbacks. Microtasks include promise reactions, code after
awaitandqueueMicrotask()callbacks. - Order: all synchronous code, then every microtask, then the next task, then its microtasks.
setTimeout(fn, 0)runsfnas a later task, never immediately.- The browser renders only between tasks, so long tasks and endless microtasks freeze the page.
- Break long work into chunks that yield with a task, or move it to a Web Worker.