JavaScript · Asynchronous JavaScript

The JavaScript Event Loop — How Asynchronous Code Really Works

6 min read Updated 2026-06-17

Practice The Event Loop interview questions

Understanding the JavaScript event loop

JavaScript is single-threaded — it runs one piece of code at a time on one call stack. Yet it handles timers, network requests, user clicks, and animations seemingly all at once. The mechanism that makes that possible is the event loop. Understanding it is the key to predicting output order, avoiding frozen UIs, and reasoning about setTimeout, promises, and async/await. This guide builds the model piece by piece.

The call stack

The call stack is a LIFO structure that tracks the function currently executing. Calling a function pushes a frame; returning pops it. There's exactly one stack, so JavaScript does one thing at a time.

function a() { b() }
function b() { c() }
function c() { return 1 }
a() // stack grows a -> b -> c, then unwinds

Because there's only one stack, any long synchronous function blocks everything until it returns — no timers fire, no clicks are handled, nothing paints.

Why single-threaded, and what "async" really means

JavaScript was designed single-threaded to keep the DOM simple: if multiple threads could mutate the DOM at once, you'd need locks everywhere. Asynchronicity doesn't add threads — the waiting (timers, I/O, network) happens outside the JS engine, in the browser or Node runtime. When that work finishes, a callback is queued to run on the same single thread once the stack is clear.

setTimeout(() => console.log('later'), 1000)
// the timer is handled by the browser; the callback runs on the JS thread later

True parallelism requires Web Workers (browser) or worker threads (Node), which run separate threads that communicate via messages.

The loop: tasks and microtasks

When an async operation completes, its callback goes into a queue. There are two priority levels:

  • Macrotasks (tasks): setTimeout, setInterval, I/O, UI events. The loop runs one per iteration.
  • Microtasks: promise .then/catch/finally, await continuations, queueMicrotask, MutationObserver.

The defining rule: after each macrotask (and after the initial synchronous script), the engine drains the entire microtask queue — including microtasks scheduled by other microtasks — before the next macrotask or any rendering.

setTimeout(() => console.log('macro'), 0)
Promise.resolve()
  .then(() => console.log('micro 1'))
  .then(() => console.log('micro 2'))
// micro 1, micro 2, macro

Both microtasks finish before the timer, even with a 0 ms delay — which is why a resolved promise always beats setTimeout(0).

A worked example

console.log('A')
setTimeout(() => console.log('B'), 0)
Promise.resolve().then(() => console.log('C'))
console.log('D')
// Output: A D C B

Walk it through: synchronous logs first (A, D); the timer callback is queued as a macrotask; the promise callback as a microtask. The script ends, microtasks drain (C), then the next macrotask runs (B). Add async/await and the rule still holds — code after an await becomes a microtask continuation:

console.log('1')
async function f() { console.log('2'); await null; console.log('3') }
f()
console.log('4')
// 1, 2, 4, 3

The body before the first await runs synchronously (2); everything after is deferred as a microtask (3), which runs after the synchronous 4.

Where rendering and requestAnimationFrame fit

The browser tries to paint at most once per frame (~16.7 ms at 60 fps), and it does so after the current macrotask and its full microtask queue have completed — never mid-task.

[1 macrotask] -> [drain all microtasks] -> [rAF callbacks] -> [style/layout/paint] -> repeat

requestAnimationFrame(cb) schedules cb right before the next repaint, synced to the display refresh and paused in background tabs — which is why it's the right tool for animation, not setTimeout. Processing one macrotask per iteration (rather than the whole queue) keeps input handling and painting responsive.

Node.js specifics

Node uses libuv and runs the loop through ordered phases — timers, pending callbacks, poll, check, close — draining process.nextTick and microtasks between every phase. Two Node-only schedulers matter:

setImmediate(() => console.log('immediate')) // "check" phase
setTimeout(() => console.log('timeout'), 0)   // "timers" phase
process.nextTick(() => console.log('nextTick'))// before everything
// nextTick first; immediate vs timeout order varies at the top level,
// but inside an I/O callback setImmediate is deterministically first

process.nextTick has even higher priority than promises, so recursively scheduling it can starve the loop entirely.

Blocking and starvation

Two ways to wedge the loop:

  • A long synchronous task never lets the stack empty, so timers, promises, input, and rendering all stall (the "page unresponsive" freeze, and poor INP).
  • Microtask starvation — microtasks that keep scheduling more microtasks drain forever, so macrotasks and rendering never get a turn.
const end = Date.now() + 3000
while (Date.now() < end) {} // freezes the tab for 3 seconds

The fix is to yield: break long work into chunks scheduled as macrotasks (setTimeout, MessageChannel, scheduler.yield()), or move CPU-heavy work into a Web Worker so the main thread stays free for input and paint.

Choosing how to defer work

queueMicrotask(fn)         // ASAP, before the next render/macrotask
Promise.resolve().then(fn) // also a microtask
setTimeout(fn, 0)          // next macrotask (after microtasks, maybe a paint)
requestAnimationFrame(fn)  // right before the next paint

Pick a microtask when the deferral must beat rendering and timers; pick a macrotask when you want to yield the thread so input and paint can happen.

Recap

JavaScript runs on one thread with one call stack; the event loop coordinates concurrency by running queued callbacks when the stack is empty. Synchronous code runs first, then the entire microtask queue drains, then one macrotask, with rendering interleaved — repeat. Promises and await are microtasks; setTimeout and I/O are macrotasks, so microtasks always win. Keep tasks short and yield for long work, and the puzzles, the freezes, and the scheduling questions all become predictable.

Practice tests are coming soon

Get notified when interactive mock interviews and quizzes launch.