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,awaitcontinuations,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.