Lesson 14 (Final) · 20 min read

Async JS and the Event Loop

Call stack, Web APIs, callback queue, microtask queue and the event loop that ties them together, stepped tick by tick — plus async/await, where rendering fits, starvation, and why setTimeout(0) is not 0.

On this page

  1. Concept
  2. Key words in plain English
  3. Why this matters
  4. The front desk — an analogy
  5. The four players
  6. The golden rule: microtasks drain first
  7. Tracing the classic example
  8. async/await — microtasks in disguise
  9. Where rendering fits in the loop
  10. Starvation — when microtasks never stop
  11. A quick note on Node.js
  12. See it live — DevTools
  13. Common misconceptions
  14. Interview questions
  15. Tying all 14 lessons together

Concept

JavaScript is single-threaded — one call stack (Lesson 2), one thing running at any given instant. On its own, that should mean a slow operation like a network request freezes everything else. It doesn't, because the JS Runtime Environment (Lesson 13) hands slow work off to the browser, and the event loop is the mechanism that decides when the results of that work are allowed back onto the call stack.

Key Words In Plain English

  • Thread is one path of execution. Single-threaded means JavaScript runs your code on one thread, so it does one thing at a time.
  • Synchronous code runs line by line, and each line finishes before the next one starts. Asynchronous work is started now, and its result is handled later, while other code keeps running.
  • Call stack is the list of function calls running right now. The engine always runs the call on top.
  • Web API is a feature that the browser (not the JavaScript engine) provides, such as setTimeout, fetch and DOM events. It does its work outside the call stack.
  • Callback is a function that you give to other code so that it can call it later.
  • Task (macrotask) queue — this lesson also calls it the callback queue — is a waiting line for finished work such as timer callbacks and click handlers. The event loop takes one item per turn.
  • Microtask queue is a second, higher-priority waiting line for promise callbacks and queueMicrotask callbacks. It is emptied completely before the next task runs.
  • Event loop is the runtime's loop that checks whether the call stack is empty and, if it is, moves the next waiting callback onto it.
  • Promise is an object that stands for a result that is not ready yet. It is pending first, then fulfilled (success) or rejected (failure). .then() registers a function to run when it is fulfilled.
  • async/await is a way to write promise code that reads like normal code. An async function always returns a promise. await pauses that function until a promise settles.
  • Starvation is when something never gets its turn because other work keeps going first.
  • Rendering (painting) is the browser drawing the page on the screen. requestAnimationFrame is a browser function that runs your function just before the next paint.
  • libuv is the C library that gives Node.js its event loop and its asynchronous file and network work.

Why This Matters

  • It's the single most commonly misunderstood part of JavaScript, and one of the most asked interview topics of all.
  • It explains real bugs: why a setTimeout(fn, 0) never runs “immediately,” and why a chain of promises can starve a UI from updating.
  • It's the final piece connecting every earlier lesson — the call stack, closures, and now the queues — into one complete mental model of what actually happens when a JS file runs.

The Front Desk — An Analogy

One clerk, two trays, and a strict habit

A single front desk clerk (the call stack) can only help one guest at a time. Long jobs — room service, laundry, a wake-up call (Lesson 11) — get handed off to background departments (Web APIs) who work independently and don't block the clerk at all.

When a background department finishes, it doesn't walk up and interrupt the clerk mid-conversation. It drops a note into one of two trays on the desk: a small Priority tray (the microtask queue — promise results) and a larger Regular tray (the callback queue — timers, clicks). The clerk only ever looks at either tray once their current guest is fully handled and the desk is completely clear. And critically, the clerk has a strict habit: empty the Priority tray all the way down to nothing before picking up even one note from the Regular tray — no matter how many new priority notes keep arriving in the meantime.

The Four Players

PlayerRoleExamples
Call StackRuns one thing at a time, synchronously (Lesson 2)Your actual running code
Web APIsDo the slow work in the background, outside the engine (Lesson 13)setTimeout, fetch, DOM events
Callback / Macrotask QueueHolds finished callbacks, waiting their turn — one is processed per event loop cyclesetTimeout callbacks, click handlers
Microtask QueueHolds finished promise callbacks — the entire queue drains every cycle.then(), .catch(), .finally(), queueMicrotask(), code after await

The event loop itself isn't a queue — it's the clerk: a continuous loop that checks “is the call stack empty?” and, the instant it is, decides what gets pulled onto it next, following the golden rule below.

Call Stack
one thing at a time
Web APIs
setTimeout, fetch...
Microtask Queue — drains fully
Callback Queue — one at a time
Event Loop
is the stack empty? drain microtasks first, always
Microtasks always win the race back onto the stack — every single one of them, before a single callback-queue item gets its turn. · the four players

The Golden Rule: Microtasks Drain First

After the currently running code finishes and the call stack is empty, the event loop runs every microtask in the queue, one after another, until it's completely empty — including new microtasks added by microtasks that were already running — before it takes even one item from the callback queue. Only once the microtask queue is fully empty does exactly one callback-queue item get its turn.

Tracing The Classic Example

classic-example.js
console.log("1: sync start");

setTimeout(() => {
  console.log("2: setTimeout callback");
}, 0);

Promise.resolve().then(() => {
  console.log("3: promise callback");
});

console.log("4: sync end");

// Output:
// 1: sync start
// 4: sync end
// 3: promise callback
// 2: setTimeout callback
  1. The synchronous code runs first, fully

    "1: sync start" logs immediately. setTimeout hands its callback to the Web API and returns instantly — it does not wait, even with a delay of 0. Promise.resolve().then(...) registers its callback into the microtask queue. "4: sync end" logs.

  2. The call stack is now empty — golden rule kicks in

    Before touching the callback queue at all, the event loop drains the microtask queue completely. There's exactly one microtask waiting: the promise callback.

  3. "3: promise callback" runs

    The microtask queue is now empty.

  4. Only now does the callback queue get its turn

    "2: setTimeout callback" finally runs — last, even though it was scheduled before the promise.

step 1 / 9
Synchronous code · call stack busy
Line 1 runs on the call stack

console.log is pushed on top of the global context, prints, and pops straight off.

1console.log("1: sync start");
2
3setTimeout(() => {
4 console.log("2: setTimeout callback");
5}, 0);
6
7Promise.resolve().then(() => {
8 console.log("3: promise callback");
9});
10
11console.log("4: sync end");
Call stack (top first)
console.log
global()
Web APIs (browser)
(nothing pending)
Microtask queue
(empty)
Callback queue
(empty)
console
› 1: sync start
The classic example, one event-loop tick at a time. Watch the timer callback wait in its queue while the promise callback jumps ahead of it. · interactive

This is why setTimeout(fn, 0) never truly means “run this immediately.” It means “run this as a macrotask, after the current code and every pending microtask are done” — always at least one full cycle later.

async/await — Microtasks In Disguise

async/await doesn't add a new queue or a new kind of scheduling — it is written on top of promises, so it follows exactly the same rules. Two facts explain every async puzzle:

  • An async function runs synchronously, on the current call stack, right up to its first await. Calling it is not deferred.
  • await pauses the function and schedules the rest of it as a microtask. The function returns a pending promise straight away, and the caller keeps running.
async-await.js
async function checkIn() {
  console.log("2: checkIn starts");
  await null;
  console.log("4: after await");
}

console.log("1: script start");
setTimeout(() => console.log("6: timeout"), 0);
checkIn();
Promise.resolve().then(() => console.log("5: then"));
console.log("3: script end");

// 1: script start
// 2: checkIn starts
// 3: script end
// 4: after await
// 5: then
// 6: timeout
step 1 / 9
Synchronous code · call stack busy
The script starts

Defining checkIn on line 1 doesn't run it. The first thing that runs is line 7.

1async function checkIn() {
2 console.log("2: checkIn starts");
3 await null;
4 console.log("4: after await");
5}
6
7console.log("1: script start");
8setTimeout(() => console.log("6: timeout"), 0);
9checkIn();
10Promise.resolve().then(() => console.log("5: then"));
11console.log("3: script end");
Call stack (top first)
console.log
global()
Web APIs (browser)
(nothing pending)
Microtask queue
(empty)
Callback queue
(empty)
console
› 1: script start
async/await on the same machinery. The function runs synchronously until await; everything after it is a microtask. · interactive

That's why “await blocks” is only half true. It pauses that one function; it never blocks the call stack, the rest of your script, or the browser. Code after an await always runs after the current synchronous code has finished — and before any timer or click handler.

Where Rendering Fits In The Loop

The browser paints the screen from the same thread that runs your JavaScript, so painting has to take a turn in the loop too. One full turn looks like this:

  1. Run one task from the callback queue

    A timer callback, a click handler, the initial script — exactly one.

  2. Drain the entire microtask queue

    Every promise callback and await continuation, including new ones added along the way.

  3. Maybe render

    If it's time for a new frame (about every 16 ms on a 60 Hz screen; the browser may skip a frame when nothing changed), run requestAnimationFrame callbacks, recalculate styles and layout, and paint.

  4. Repeat

    Pick the next task, and go round again.

Two practical rules fall straight out of that order. Any single task or microtask chain that runs for 300 ms delays the next paint by 300 ms — the page looks frozen, and clicks feel ignored. And visual updates belong in requestAnimationFrame, which runs right before the paint, rather than in a setTimeout that may fire between frames.

rendering.js
// Blocks rendering: the browser can't paint until this task ends
button.addEventListener("click", () => {
  box.style.transform = "translateX(0)";
  heavyWork(); // 300 ms of synchronous work — the page freezes
  box.style.transform = "translateX(200px)";
});

// Runs just before the next paint — the right place for visual updates
let x = 0;
function animate() {
  box.style.transform = `translateX(${x++}px)`;
  if (x < 200) requestAnimationFrame(animate);
}
requestAnimationFrame(animate);

Starvation — When Microtasks Never Stop

starvation.js
function scheduleMicrotaskForever() {
  Promise.resolve().then(() => {
    console.log("microtask running...");
    scheduleMicrotaskForever(); // queues another one before this one even finishes
  });
}

scheduleMicrotaskForever();

setTimeout(() => {
  console.log("this setTimeout may never run");
}, 0);
step 1 / 6
Synchronous code · call stack busy
A timer is scheduled

Its callback lands in the callback queue almost immediately.

1function loop() {
2 Promise.resolve().then(() => {
3 console.log("microtask");
4 loop(); // queues another before this one ends
5 });
6}
7
8setTimeout(() => console.log("timeout"), 0);
9loop();
Call stack (top first)
setTimeout
global()
Web APIs (browser)
timer 0 ms → timeout cb
Microtask queue
(empty)
Callback queue
(empty)
console
(nothing yet)
A microtask that keeps queueing microtasks. The callback queue — and rendering — never get a turn. · interactive

Because the microtask queue must be fully drained before the callback queue gets a single turn, a microtask that keeps scheduling another microtask can — in principle — keep the callback queue (and browser rendering, which also waits for an empty microtask queue) starved indefinitely. This is a genuine, documented failure mode in real applications with runaway promise chains, not just a theoretical curiosity.

A Quick Note On Node.js

Everything above describes the browser's event loop. Node.js runs the same core idea — one call stack, a microtask queue that drains fully between macrotasks — but its macrotask side is organized into distinct phases (timers, pending callbacks, poll for I/O, check for setImmediate, close callbacks) managed by a library called libuv, and it adds its own extra-high-priority queue, process.nextTick(), which runs before promise callbacks. The mental model from this lesson transfers directly; Node's phase system is worth a dedicated look if you're going deep on backend work, but it's outside the scope of this core-JavaScript block.

See It Live — DevTools

  1. Paste the classic example into the console

    Confirm the output order matches the trace above.

  2. Open the Performance tab and record

    Re-run the snippet with recording on — you'll see the synchronous block, then a microtasks marker, then the timer firing as a separate task.

  3. Try nesting more .then() calls

    Chain three or four .then()s after the first — notice they all still run before the single setTimeout, because each one re-queues into the still-draining microtask queue.

  4. Swap the delay to 1000

    The relative order doesn't change — the promise callback still always wins, regardless of the timer's delay, because the golden rule isn't about speed, it's about queue priority.

Common Misconceptions

“JavaScript can run things in parallel because of async code.” No — there is still only one call stack. Async APIs create the appearance of concurrency by offloading work elsewhere and scheduling the result, never by running your JS on more than one thread at once.

“A Promise's executor function itself runs asynchronously.” It doesn't — the function you pass to new Promise((resolve, reject) => {...}) runs synchronously, immediately. Only .then()/.catch()/.finally() callbacks are deferred as microtasks.

“setTimeout(fn, 0) queues fn to run next, right after the current line.” It queues fn as a macrotask — which still waits behind the entire, possibly-growing microtask queue before it gets a turn.

Interview Questions

Why doesn't a slow network request freeze the JavaScript call stack?

The request is handed off to a Web API outside the JS engine; the call stack moves on immediately, and only the eventual result is scheduled to come back onto the stack later, via a queue and the event loop.

What is the exact priority rule between the microtask queue and the callback queue?

After the call stack empties, the entire microtask queue is drained — including any new microtasks queued during that draining — before even one item from the callback queue is processed.

Does setTimeout(fn, 0) run before or after a Promise.resolve().then() registered afterward?

After — the promise callback is a microtask and always drains before the setTimeout callback, a macrotask, gets its turn, regardless of the order they were registered in.

What causes callback-queue starvation?

A microtask that keeps scheduling another microtask before the queue can empty — since the callback queue (and rendering) only gets a turn once the microtask queue is completely drained.

Are Web APIs like setTimeout and fetch part of the JavaScript engine?

No — they're provided by the surrounding runtime environment (the browser or Node.js), not by the JS engine itself, which only knows how to parse, compile, and execute JavaScript syntax.

When does the code after an await run?

As a microtask. The async function runs synchronously up to the await, then returns a pending promise; the rest of the function is queued and runs after the current synchronous code finishes — before any setTimeout callback, but in order with other microtasks.

Predict the output: console.log(1); setTimeout(() => console.log(2)); Promise.resolve().then(() => console.log(3)); (async () => { console.log(4); await null; console.log(5); })(); console.log(6);

1 4 6 3 5 2. Synchronous first (1, then the async function up to its await prints 4, then 6), then microtasks in the order they were queued (the .then printing 3, then the continuation printing 5), and finally the timer (2).

Why can a long-running promise chain freeze the page even though no single step is slow?

Rendering only happens between tasks, after the microtask queue is completely empty. If microtasks keep queueing more microtasks, the queue never empties, so the browser can't paint or handle input — the same starvation that blocks the callback queue.

Tying All 14 Lessons Together

What actually happens when your JS file runs

The engine (Lesson 13) parses your file and builds a Global Execution Context (Lesson 1), pushing it onto the call stack (Lesson 2). During its memory phase, vars and function declarations are hoisted (Lessons 1, 8), while let/const sit in the Temporal Dead Zone (Lesson 8) inside their own block scope (Lesson 9). As functions are called, each gets its own execution context and Variable Environment (Lesson 4), linked outward through the scope chain (Lesson 7) to whatever it could see at the moment it was defined — the mechanism that also gives functions their closures (Lesson 10), letting them outlive the very call that created them (Lesson 11). Along the way, functions pass freely as values (Lesson 12) — as callbacks, as event listeners — and whenever one of those needs to wait, it steps outside the call stack entirely, into Web APIs and the two queues this lesson just walked through, coordinated the whole time by the event loop. That's the full model, start to finish.

Published by Jainik · Last updated · How these lessons are made

Keep learning

Part of the study guide

Related series