JavaScript event loop
Full anatomy of the HTML event loop: task sources, microtask checkpoints, rendering opportunities, and how Node phases differ.
The mental model
JavaScript engines do not have a scheduler in the traditional OS sense. They have an event loop, which is a while (true) that asks one question: "is there work to do?" The answer comes from a small set of queues. The loop drains them in a strict order and never preempts user code. Once a function starts running, it runs to the end. No other JavaScript runs in parallel on the same agent (the spec word for what you can think of as a tab or worker).
This is the single most important fact: cooperative scheduling. If you write a loop that runs for 5 seconds, the page is frozen for 5 seconds. The browser cannot interrupt you. It can only wait.
Tasks and task sources
The spec defines a task as "an algorithm that performs work, possibly producing side effects." Each task comes from a task source: timer task source, DOM manipulation task source, user interaction task source, networking task source, history traversal task source, and so on. The loop picks one task per iteration, runs it to completion, then moves on.
Why separate sources? So the user agent can prioritize. A browser is allowed to pick the next user-input task before the next timer task, because the spec only requires within-source ordering. In practice, browsers do exactly that. Click events are not starved by timers.
A task is "one go." Parsing a chunk of HTML is a task. Dispatching a click event handler is a task. Resolving the body of a setTimeout is a task. The function called by addEventListener is run inside a task, not as its own task.
Microtasks: the in-between
After every task finishes, before the loop moves on, the engine performs a microtask checkpoint. It drains the entire microtask queue. If draining a microtask queues more microtasks, those run too. The checkpoint only ends when the queue is empty.
Sources of microtasks:
- Promise reactions (the callback you pass to
.then,.catch,.finally) queueMicrotask(fn)MutationObservercallbacks- The continuation after
await
The "drain to empty" rule is what makes promise chains feel synchronous compared to timers. Promise.resolve().then(a).then(b).then(c) runs a, b, c in one microtask checkpoint. No paint, no timer, no I/O in between.
Promise.resolve().then(() => {
console.log("a");
Promise.resolve().then(() => console.log("b"));
});
setTimeout(() => console.log("c"), 0);
// a, b, cb is queued during the microtask checkpoint, so it runs before c even though c was queued first as a macrotask.
This is also how you starve the loop. An infinite microtask chain never lets a task run, so timers never fire, I/O callbacks never fire, the page never repaints. You cannot do this with setTimeout because timers are tasks, and rendering gets a chance between tasks.
Rendering: the second-class citizen
The spec says the browser MAY render after a task. It does not have to, and it usually does not at 60fps. Rendering is expensive, and the browser tries to batch it to the display refresh rate (usually 16.67ms per frame).
What this means in practice: if you mutate the DOM inside a microtask, the next microtask sees the mutated tree, but the user does not see the paint until the engine decides to render. That is why requestAnimationFrame exists. RAF callbacks run after microtasks, after the resize and scroll steps, and right before layout and paint. They are your hook into the render pipeline.
If you do el.style.left = "100px" and immediately read el.offsetLeft, you force a synchronous layout (a "reflow"). The engine cannot return the value without computing it. Doing this in a loop is the classic "layout thrashing" anti-pattern. Read all measurements first, then write all mutations.
Node.js: same shape, different phases
Node runs on libuv, which gives it a phased event loop. Each "tick" goes through phases in order:
- Timers:
setTimeoutandsetIntervalcallbacks whose time has elapsed - Pending callbacks: some system-level callbacks (deferred I/O errors)
- Idle, prepare: internal
- Poll: retrieve new I/O events, run their callbacks
- Check:
setImmediatecallbacks - Close callbacks:
socket.on('close', ...)
Between every callback in every phase, Node drains two queues: process.nextTick callbacks first, then microtasks (promises). nextTick is Node-only and runs before promise microtasks. Use it sparingly. It is a footgun for starvation.
setImmediate(() => console.log("immediate"));
setTimeout(() => console.log("timeout"), 0);
// Order from the main module is not deterministic
// Order from inside an I/O callback: immediate first, then timeout next tickThe reason the order is non-deterministic from the main module is that the timer might or might not have elapsed by the time the timers phase runs on the first tick. From inside an I/O callback, you are in the poll phase, so the next phase is check, so setImmediate always wins.
Common pitfalls
The biggest one: assuming await yields to other tasks. It only yields to other microtasks. If you await a resolved promise in a tight loop, you are not letting timers or I/O run. You are just scheduling a microtask continuation.
The second: setTimeout(fn, 0) is not 0ms. The HTML spec mandates a minimum of 4ms after five nested timers in the same chain. Browsers also throttle to 1000ms in background tabs. If you need a real "run after this task," use queueMicrotask (runs in the same task's microtask checkpoint) or MessageChannel (queues a real task without the 4ms penalty).
The third: thinking promises are async. The body of new Promise(executor) runs synchronously. Only the .then callbacks are microtasks. new Promise(resolve => { console.log("now"); resolve(); }).then(() => console.log("later")) prints "now" before "later" because the executor is synchronous.
The fourth: long tasks block input. The browser has a metric called Interaction to Next Paint (INP) which measures the worst input latency on the page. Any task over 50ms is a "long task" and shows up in Chrome DevTools. Breaking work into smaller tasks with MessageChannel or scheduler.yield() is how you keep INP low.
Why this design
The event loop is the result of one constraint: JavaScript was added to Netscape in 10 days as a scripting language for a single-threaded DOM. The DOM was not designed to be touched by two threads at once, and rewriting it to be thread-safe was never on the table. So the runtime stayed single-threaded and the async model was bolted on with callbacks, then promises, then async/await.
The upside is that you never have a data race in your own code. The downside is that you have to think about scheduling. A senior engineer's mental model is not "I have multiple threads to coordinate," it is "I have one thread and I must not block it." Once you internalize that, the event loop becomes obvious.
Learn more
- DocsHTML spec: Event loopsWHATWG
- Talk
- Docs
- Article