The Event Loop: How JavaScript Does Two Things at Once Without Actually Doing Two Things at Once
JavaScript is single-threaded. One call stack, one thing executing at a time, no exceptions. And yet your code sets timers, fires off network requests, listens for clicks, and animates the UI — all without blocking each other
Abhishek Kumar
Published on August 2, 2026
The Event Loop: How JavaScript Does Two Things at Once Without Actually Doing Two Things at Once
JavaScript is single-threaded. One call stack, one thing executing at a time, no exceptions. And yet your code sets timers, fires off network requests, listens for clicks, and animates the UI — all without blocking each other. The event loop is the entire reason that's possible, and it's also one of the most reliably-asked questions in senior frontend interviews, because "I know it runs callbacks later" isn't actually an answer. The real answer lives in the ordering rules, and almost every "why did this log in that order" bug traces back to one of them.
This article builds the mental model from the ground up: the call stack, the Web APIs that do the actual waiting, the two separate task queues, and the strict priority between them. Then it stress-tests that model against the kind of code that shows up in interviews specifically because it's confusing.
Start with what JavaScript actually is at runtime: a call stack. Every function call pushes a frame onto it; every return pops one off. Nothing else runs while the stack has anything on it — that's what "single-threaded" means in practice.
function multiply(a, b) { return a * b; }function square(n) { return multiply(n, n); }function printSquare(n) { console.log(square(n)); }printSquare(5);
Call stack over time: printSquare pushes → calls square, which pushes → calls multiply, which pushes, returns, pops → square returns, pops → printSquare logs, returns, pops. Stack empty. This part is just how every language's call stack works — nothing JS-specific yet.
The JS-specific problem shows up the moment you write this:
If the stack is the only mechanism, setTimeout's callback would have to block everything for 1000ms before 'end' could even log. It doesn't — the output is start, end, timeout. So something outside the stack is holding that callback and waiting. That something is the browser (or Node's libuv in a non-browser environment) — not the JS engine itself.
setTimeout, fetch, DOM event listeners, and most things that feel "asynchronous" aren't part of the JavaScript language at all — they're APIs the host environment (browser or Node) provides. When you call setTimeout(fn, 1000), the JS engine doesn't wait 1000ms. It hands the timer off to the browser, which starts a real timer on its own, and the call stack immediately moves on to the next line.
So the moving parts are:
Call stack — synchronous execution, one frame at a time.
Web APIs / Node APIs — where timers count down, network requests wait for a response, file reads happen. This is genuinely concurrent, running outside the JS engine.
Callback queues — once a Web API finishes its work (the timer elapses, the response arrives), it doesn't run the callback immediately. It places the callback into a queue.
The event loop — a process that continuously checks one thing: is the call stack empty? If yes, it takes the next callback from a queue and pushes it onto the stack. If no, it waits.
That's the whole mechanism. The event loop is not a scheduler with priorities and interrupts — it's a simple, repeating check: stack empty → pull from queue → run to completion → check again.
Here's where most explanations stop too early, and where almost every tricky interview question lives: there isn't one callback queue, there are (at least) two, and they are not treated equally.
The macrotask queue (sometimes just called "the task queue" or "callback queue") holds things like setTimeout/setInterval callbacks, DOM events, and I/O callbacks.
The microtask queue holds Promise callbacks (.then, .catch, .finally), async/await continuations, and queueMicrotask.
The rule that decides everything: after every single macrotask finishes, the event loop drains the entire microtask queue — completely, including any new microtasks that got added while draining — before it runs even one more macrotask.
Not "microtasks get priority." Not "microtasks run first once." All of them, every time, exhaustively, between every macrotask.
1 and 4 run synchronously — they're on the call stack right now, nothing queues them.
setTimeout, even with a 0ms delay, hands its callback to the Web API layer, which queues it as a macrotask. A 0ms delay does not mean "run next" — it means "queue this macrotask as soon as possible," and macrotasks always wait behind the full microtask drain.
Promise.resolve().then(...) queues its callback as a microtask.
The promise callback runs before the timeout callback even though both were "ready" at essentially the same instant, because the microtask queue is fully drained before the next macrotask is even considered — regardless of which was scheduled first or which has a shorter delay.
Push the rule one step further, because "drain the entire queue, including newly added ones" has a sharp edge: if a microtask callback queues another microtask, that new one also runs before the next macrotask. A chain of self-perpetuating microtasks will run to completion — however long that takes — before the browser gets to paint a frame or fire the next timer.
function loopMicrotasks() { Promise.resolve().then(() => { console.log('microtask running...'); loopMicrotasks(); // queues another microtask from inside a microtask });}setTimeout(() => console.log('this may never run'), 0);loopMicrotasks();
This is a real production failure mode, not a puzzle-box gotcha — a recursive .then() chain, or a MutationObserver callback that triggers more mutations, can monopolize the microtask queue and visibly freeze UI updates, because the browser can't get back to rendering or firing timers until the microtask queue is empty. Macrotasks (which include rendering) only get a turn once there is nothing left in the microtask queue at all.
async/await Is Promise Syntax, Not a New Mechanism#
async/await doesn't introduce a third queue or bypass any of this — it's sugar over the exact same microtask machinery. Every await is a .then() in disguise: the code after an await is scheduled as a microtask continuation, resuming only once the awaited value resolves and the microtask queue reaches it.
console.log('1');async function asyncFn() { console.log('2'); await null; // suspends here; the rest becomes a microtask console.log('4');}asyncFn();console.log('3');
Output: 1, 2, 3, 4. asyncFn() runs synchronously up to await null — '2' logs immediately, no queueing yet. await then suspends the function and schedules everything after it as a microtask, so control returns to the caller and '3' logs before the function resumes. '4' finally runs once the call stack clears and the microtask queue is processed. This is identical in timing to writing Promise.resolve(null).then(() => console.log('4')) — async/await just reads top-to-bottom instead of nesting callbacks.
Everything above describes the browser's event loop. Node.js runs on libuv, which implements a more elaborate event loop with distinct phases that execute in a fixed order each cycle — timers, pending callbacks, idle/prepare, poll, check, close callbacks — and the loop cycles through all of them, then repeats.
The detail that trips people up: process.nextTick() is not a microtask, despite behaving similarly. It has its own queue, and that queue is drained completely — even ahead of the promise microtask queue — between every single phase transition, not just once per full loop cycle. So in Node, the actual priority order is: process.nextTick queue → microtask (Promise) queue → then whichever libuv phase is next. A process.nextTick call inside a microtask callback still jumps ahead of remaining microtasks, the same starvation risk as above but one level higher in priority.
One more piece worth having explicit, because it explains a category of "why is my UI not updating" bugs: in browsers, layout and paint happen as part of the macrotask cycle, roughly between macrotasks and before the next one runs (synced to the display's refresh rate via the same mechanism requestAnimationFrame hooks into). Since the microtask queue must be fully empty before that can happen, a long or self-perpetuating microtask chain doesn't just delay the next setTimeout — it delays the browser painting anything at all. This is the mechanical reason "my animation stutters" and "why did this microtask starve my timer" are, underneath, the same bug.
Assuming setTimeout(fn, 0) runs immediately. It queues a macrotask as soon as possible — after the current stack clears and the entire microtask queue drains. It is never "next line," and browsers also commonly clamp very small delays to a minimum (historically 4ms for nested timers), so 0 is a lower bound request, not a guarantee.
Treating microtasks and macrotasks as one queue with vague priority. The rule is exhaustive draining, not a one-time head start — recursive microtask scheduling can starve macrotasks (and rendering) indefinitely.
Forgetting process.nextTick outranks promises in Node. Code that works one way in the browser console can reorder in Node specifically because of this extra, higher-priority queue.
Assuming await yields to the browser/renderer. It only yields to the microtask queue. If you need to actually let the browser paint or handle other events, you need a macrotask boundary — await alone won't force one.
Reasoning about async order without knowing where a callback lands. Before predicting execution order, always ask: is this specific callback a microtask or a macrotask? — DOM events, timers, and I/O callbacks are macrotasks; Promises, async/await continuations, and queueMicrotask are microtasks; process.nextTick in Node is its own, higher-priority thing entirely. Get that classification right and the ordering falls out mechanically — there's no other trick to it.