Event Loop in JavaScript - how the engine decides what runs next

JavaScript has one thread, but it can wait for many things at once. The trick is the event loop - a simple algorithm that picks what to run when the stack is empty. Microtasks beat tasks, render fits between cycles, and execution order starts to make sense.
Four lines that look like a puzzle on first contact:
console.log("1");
setTimeout(() => console.log("2"), 0);
Promise.resolve().then(() => console.log("3"));
console.log("4");Intuition says 1, 2, 3, 4. Or 1, 3, 4, 2, since setTimeout is delayed and should fire last. The actual output is 1, 4, 3, 2.
Two questions to answer:
- Why is
4before2, givensetTimeout(0)has “zero” delay? - Why is
3before2, even thoughsetTimeoutcame first in the code?
The answer: the event loop. The mechanism that decides what JavaScript runs next when the stack is empty. The rest of the post breaks this algorithm down: one thread, two callback queues, render step. By the end, the output 1, 4, 3, 2 will stop being a riddle.
#JavaScript is single-threaded - which is why it needs an event loop
The JS engine has one stack and one thread of execution. One line of code at a time, to completion, before the engine takes the next. Anything that looks “concurrent” in code actually happens one step after another. I covered the model in the execution context post - here we care about what follows from it.
Operations that take time (timers, network requests, waiting for a click) cannot happen “inside” JavaScript. If they tried, the single thread would block. setTimeout(callback, 1000) would freeze the script for a second. The first fetch would freeze the UI for 100ms.
That’s why those operations live outside JavaScript, in the host environment (browser or Node.js). The host has its own threads for timers, network, and user events - that’s where these operations actually run. JavaScript hands the work off (“wake me up in a second”) and goes back to its own job. The host calls back only when the result is ready.
This connection - between the JS thread and ready-to-run callbacks from the host - is what the event loop handles.
#What makes up the event loop
The mechanism has four pieces. Each plays a specific role:
Call stack - where code physically runs. A function enters (push), finishes (pop). Full description in the execution context post.
Web APIs / host environment - functionality outside JavaScript, provided by the browser or Node.js. In the browser: timers, fetch, DOM events, IndexedDB. In Node.js: fs, http, setImmediate. Each of them, at some moment, has a callback to fire.
Task queue (also known as callback queue or macrotask queue) - the queue where the host puts callbacks once they’re ready to run. This is where a setTimeout callback lands after its delay, and where a DOM event handler lands after a click.
Microtask queue - the second queue, with higher priority than the task queue. This is where callbacks from Promise.then / .catch / .finally, continuations after await, and anything scheduled via queueMicrotask() land.
The event loop is the algorithm that coordinates the four: it checks the queues, puts callbacks on the stack, checks again. It runs non-stop as long as the page is alive.
#Task queue - one task per cycle
Simplest example:
console.log("synchronous");
setTimeout(() => console.log("after a second"), 1000);
console.log("still synchronous");Step by step:
- The engine enters
console.log("synchronous"), prints, exits. Console:synchronous. - The engine enters
setTimeout(callback, 1000).setTimeoutitself doesn’t fire the callback - it just tells the browser “run this callback for me in 1000ms”. It returns immediately, no waiting. - The engine enters
console.log("still synchronous"), prints. Console:still synchronous. - Script done. The call stack is empty.
- After 1000ms the browser does its job: takes the timer’s callback and puts it into the task queue.
- The event loop sees an empty stack and a non-empty task queue. It pulls the callback, drops it on the stack, the engine runs it. Console:
after a second.
What lands in the task queue:
setTimeout/setIntervalcallbacks- DOM events (click, keydown, scroll)
- Network request callbacks (
onload,onerror) - the entire script execution (
<script>) - even page startup is itself a task
The most important rule: per cycle, the event loop pulls exactly one callback from the task queue. It runs that one to completion, then deals with microtasks, then maybe with render (more on that shortly - it’s the moment the browser repaints the screen). Only on the next cycle does it grab the following callback from the task queue. It never takes two tasks in a row.
#Microtask queue - the whole queue per cycle
Promise.then behaves differently:
console.log("synchronous");
Promise.resolve("ok").then((value) => console.log(value));
console.log("still synchronous");Output: synchronous, still synchronous, ok.
The Promise callback doesn’t go into the task queue. It goes into the microtask queue. The difference looks subtle, but has one critical effect:
per cycle, the event loop drains the entire microtask queue to the bottom. Concretely: it grabs the first callback in the microtask queue and runs it. Grabs the second - runs it. Third - runs it. And so on, until the queue is empty.
For comparison: from the task queue the event loop takes only one callback per cycle. From the microtask queue it takes all of them, one after another, until they’re done.
What lands in the microtask queue:
Promise.then/.catch/.finallycallbacks- continuations after
await(the code afterawaitin anasyncfunction) - covered in the async/await post queueMicrotask(fn)- explicit microtask schedulingMutationObservercallbacks (DOM changes)
Everything on this list has higher priority than callbacks from the task queue. When the stack is empty, the event loop drains the entire microtask queue first, only then reaches for the next callback from the task queue.
#The event loop algorithm - the official order
It all comes together in the algorithm defined in HTML Standard § 8.1.7 Event loops. Pseudocode (simplified):
while (true) {
// 1. Take one task from the task queue (if any)
const task = taskQueue.shift();
if (task) executeUntilStackEmpty(task);
// 2. Drain the ENTIRE microtask queue
while (microtaskQueue.length > 0) {
const microtask = microtaskQueue.shift();
executeUntilStackEmpty(microtask);
}
// 3. Maybe render (the browser runs a task from the rendering task
// source in step with the screen refresh - shown here as a separate
// step for clarity)
if (shouldRender()) {
runAnimationFrameCallbacks();
paint();
}
}Three words to remember: task → microtasks → render. In that order, every iteration.
This is easier to see than to read. Below is one full event loop cycle step by step - including the moment when a microtask schedules another microtask during the drain. That added microtask still runs in the same cycle, before render gets a chance:
What follows: between every task, the engine runs all pending microtasks. If a task scheduled 5 microtasks - all 5 run before the next task. If each of those 5 scheduled another microtask - those run in the same iteration too. The microtask queue drains completely, including microtasks added during the drain.
That’s the source of our unintuitive output 1, 4, 3, 2: a callback from Promise.then always runs before a callback from setTimeout, even when setTimeout came first in the code. Microtasks beat tasks from the task queue, regardless of code order.
#Render step - where requestAnimationFrame fits in
“Render” is the moment when the browser paints pixels on the screen. If JavaScript changes something in the DOM (color, content, position, CSS class), the change shows up only when the event loop reaches the render step. Anywhere in this post you see the word “render” - that’s exactly the moment we mean. console.log() doesn’t require render - the DevTools console is a separate mechanism, not part of drawing the page.
Render fires roughly ~60 times per second (16.7ms per frame), in sync with the monitor’s refresh rate. Each cycle of the event loop: task → microtasks → maybe render → next task. And so on, in a loop.
For animations you use requestAnimationFrame (shortened to rAF), not setTimeout(0):
requestAnimationFrame(() => {
element.style.transform = "translateX(100px)";
});rAF registers a callback right before the next frame is painted - the DOM change makes it on screen in the same frame, no tearing relative to the refresh. setTimeout(0) lands in the regular task queue (not synced with the screen), so the callback can fire between frames and your animation will stutter.
The consequence of the order task → microtasks → render for visible changes:
- A DOM change in a microtask (e.g. callback from
Promise.then) → visible in the current frame. The microtask runs before render in the same cycle. - A DOM change in the next task (e.g. callback from
setTimeout(0)) → visible only in the next frame. A new task means a new event loop cycle, with its own render at the end.
#Breaking the opening down to atoms
Back to the original code:
console.log("1");
setTimeout(() => console.log("2"), 0);
Promise.resolve().then(() => console.log("3"));
console.log("4");Apply the schema from the previous section (TASK → MICROTASKS → RENDER):
- The whole script is one task. It runs top to bottom on the stack.
console.log("1")- prints. Console:1.setTimeout(callback, 0)- the engine registers the callback in Web APIs. After the delay the browser puts it in the task queue.Promise.resolve().then(callback)- the engine puts the callback straight into the microtask queue.console.log("4")- prints. Console:1, 4.- Script-task is done. MICROTASKS phase: the event loop drains the microtask queue. The callback from
Promise.thenruns - prints3. Console:1, 4, 3. - Microtask queue is empty. Next iteration - the TASK phase grabs the callback from the task queue. It runs - prints
2. Console:1, 4, 3, 2.
The key part: between steps 5 and 7 the microtask queue is fully drained before the event loop touches the task queue. That’s why 3 beats 2, even though setTimeout came earlier in the code.
#Practical traps
#Microtask starvation - how to starve the renderer
If a microtask schedules another microtask, the queue never empties. Render never happens. The UI freezes:
function loop() {
Promise.resolve().then(loop); // ❌ blocks render forever
}
loop();Every call to loop() adds another loop() to the microtask queue. The event loop never finishes draining, never reaches render, the page stops responding.
The fix: schedule the repeat as a task (setTimeout(0)), not a microtask. Then between iterations the event loop gets to breathe - render happens, clicks register.
#await in a loop vs Promise.all
The classic mistake:
// ❌ Sequential - each fetch waits for the previous one
for (const url of urls) {
const data = await fetch(url); // ~100ms × N requests
}// ✅ Parallel - all fetches start at the same time
const results = await Promise.all(urls.map((url) => fetch(url)));
// ~100ms totalIn the sequential version each await pauses the function. The continuation after await lands in the microtask queue only after the response arrives - so the next fetch doesn’t even start until the previous one returns.
In the parallel version all fetches start at the same time. The browser runs them side by side on its end (each request is a separate network connection). Promise.all returns only once they’ve all settled.
Each fetch waits for the previous one.
All fetches start at the same time.
The left side hits ~1800ms (3 × 600ms in series). The right finishes in ~600ms. The difference isn’t that the event loop suddenly does things in parallel - it’s that the host (the browser) can run multiple requests at the same time. await in a loop deliberately throws that capability away.
#Long sync work blocks the event loop
function expensiveOperation() {
for (let i = 0; i < 1_000_000_000; i++) {
/* CPU work */
}
}
expensiveOperation(); // ❌ blocks the event loop for secondsWhile the synchronous loop is running, the call stack isn’t empty. The event loop won’t take any task, microtask, or render. The UI freezes, clicks aren’t registered, animations stand still.
Two reasonable fixes:
- Chunking - split the work into pieces separated by
setTimeout(0). Each piece is a separate task, so between them the event loop gets to breathe: drains microtasks, runs render, picks up clicks. - Web Workers - for genuinely heavy CPU work. A worker is a separate thread with its own event loop, talking to the main thread via
postMessage. The main thread stays free for the UI.
Trap: queueMicrotask is not a fix for long work. Microtasks beat render, so queueMicrotask(longWork) blocks the frame paint exactly the same as a synchronous call. It’s a tool for defer to the end of the current task, but before render - meant for short operations, not heavy compute.
#Mental model
The event loop boils down to three things:
- One thread. Anything that looks “concurrent” in JavaScript is actually sequential - just cleverly arranged by the event loop.
- Microtasks beat tasks. After every task the event loop drains the entire microtask queue to the bottom, only then moves on to render and the next task.
- Render is a special task the browser runs in step with the screen refresh. It can be blocked by microtask starvation - microtasks scheduling more microtasks in a loop, so the queue never empties and render never gets a chance.
When you see unintuitive async code, ask three questions:
- What’s synchronous, what’s asynchronous here? Synchronous goes on the stack right away. Asynchronous (
setTimeout,fetch,Promise.then) goes to the host or to a queue. - Which queue does the callback go into? Promise / await / queueMicrotask → microtask queue. setTimeout / DOM events / network → task queue.
- When will the stack be empty? Because that’s when the event loop pulls anything off the queues.
Back to 1, 4, 3, 2, in three moves:
- Script is a task - the engine prints
1and4. - Microtask checkpoint - prints
3. - Next iteration, next task from the task queue - prints
2.
Three phases, one algorithm. Designed to be predictable - and it is, once you know the rules.
Comments
Loading comments…