Run this in your browser console and guess the order before you hit enter:
console.log("1");
setTimeout(() => console.log("2"), 0);
Promise.resolve().then(() => console.log("3"));
console.log("4");
If you guessed 1, 2, 3, 4, that's the reasonable guess, and it's wrong. The actual output is 1, 4, 3, 2. A setTimeout with a delay of zero milliseconds does not run immediately, and a Promise callback jumps ahead of it even though the Promise was written second. Neither of these is a bug. Both are the JavaScript engine doing exactly what it's supposed to do, and once you understand the event loop, this stops being surprising and starts being predictable.
Welcome to BytebaseX. I'm Suptojit Modak, and in this guide, I'll walk through what the call stack, the callback queue, and the microtask queue actually are, why they explain the output above, and how this connects directly to Promises and async/await and debouncing and throttling, two topics that only fully make sense once the event loop clicks.
JavaScript Is Single-Threaded, and That's the Whole Story
JavaScript can only do one thing at a time. There's a single call stack, and whatever function is currently running has to finish (or explicitly hand control back) before the next thing can run. This is why a single slow, synchronous function can freeze an entire web page. There's no background thread quietly picking up the slack.
So the natural question is: if JavaScript can only do one thing at a time, how does it handle a network request that takes two seconds without freezing the page for those two seconds? The answer is that the slow part never actually happens inside JavaScript's single thread at all.
Where the Waiting Actually Happens: Web APIs
Things like setTimeout, fetch, and DOM event listeners aren't part of the JavaScript language itself. They're provided by the browser (or, in Node.js, by the runtime), running outside the single-threaded call stack entirely. When you call setTimeout(fn, 1000), JavaScript hands the timer off to the browser and immediately moves on to the next line. The browser handles the actual waiting, and only brings fn back to JavaScript once the delay is up.
The call stack never waits for anything. It hands off slow work to the browser, keeps running other code, and picks the result back up later through the event loop.
Two Queues, Not One
Once a Web API finishes its work, it doesn't jump straight back into the call stack. It gets placed into a queue first, and the call stack only picks new work off a queue once it's completely empty. There are two separate queues, and they don't have equal priority:
- The macrotask queue (callback queue): holds callbacks from things like
setTimeout,setInterval, and DOM events. - The microtask queue: holds callbacks from Promises (
.then,.catch,.finally) andqueueMicrotask.
The rule that explains the entire example at the top of this post: after every single task, the event loop fully drains the microtask queue before it takes even one item from the macrotask queue. Not some microtasks. All of them, including any new ones added while draining the queue.
A Live Demo: Watching the Order Happen
Click the button below to run the exact example from the intro, one step at a time, and watch which queue each piece of code lands in.
Call Stack (runs now)
Microtask Queue
Macrotask Queue
Console output: (not run yet)
Async/Await Uses the Microtask Queue Too
This is exactly why await behaves the way it does. When you await a Promise, everything after that line is effectively scheduled as a microtask, the same queue regular .then() callbacks use. If you've read our guide on Promises and async/await, this is the missing piece underneath the syntax: async/await isn't a different execution model, it's the exact same microtask-based system, written to look synchronous.
console.log("A");
async function example() {
console.log("B");
await null; // pauses here, resumes as a microtask
console.log("C");
}
example();
console.log("D");
// Output: A, B, D, C
"B" runs immediately since everything before the first await runs synchronously. But the moment await is hit, the rest of the function is deferred to the microtask queue, so "D" (a synchronous line outside the function) runs before "C" does.
Why setTimeout(fn, 0) Doesn't Mean "Right Now"
The delay you pass to setTimeout is a minimum, not a guarantee. It means "don't run this before X milliseconds," not "run this exactly at X milliseconds." Even with a delay of 0, the callback still has to go through the Web API, land in the macrotask queue, and wait for the call stack to be completely empty and the microtask queue to be fully drained first. On a busy page, that could mean a "0ms" timeout actually firing several milliseconds, or occasionally much longer, after it was scheduled.
This is directly relevant if you've used setTimeout inside a debounce or throttle implementation. The delay you set is a floor, not a precise clock. For most UI work this doesn't matter, but it's worth knowing if you're ever debugging why a "should have fired by now" timer seems a few milliseconds late.
Common Mistakes
Assuming Multiple setTimeout Calls Fire in Delay Order
If you schedule setTimeout(a, 100) and then setTimeout(b, 50), b will generally fire first, since it has the shorter delay, regardless of which one was scheduled first in your code. Order between different timers is based on when their delay expires, not the order they were written.
Blocking the Main Thread with Heavy Synchronous Code
Since there's only one call stack, a long-running synchronous loop (sorting a huge array, a heavy calculation) blocks everything else, including UI rendering and any queued microtasks or macrotasks, until it finishes. No amount of Promises or async/await fixes this, since none of them make synchronous code non-blocking. The actual fix is breaking the work into smaller chunks, or moving it off the main thread with a Web Worker.
Thinking Promises Are "Faster" Than setTimeout
Promises aren't fast because they're better optimized, they're fast because microtasks are checked before macrotasks on every cycle. It's a queue-priority difference, not a performance difference. A Promise.resolve().then() and a setTimeout(fn, 0) both take effectively no time to execute once they run; the microtask one just gets to run sooner in the queue order.
Quick Reference
| Concept | What it means |
|---|---|
| Call stack | Where currently executing code runs, one thing at a time |
| Web APIs | Browser/runtime features (timers, network, DOM events) that run outside the call stack |
| Macrotask queue | Holds setTimeout, setInterval, and DOM event callbacks |
| Microtask queue | Holds Promise callbacks and queueMicrotask, always drained first |
| Event loop | The process that moves tasks from the queues into the call stack once it's empty |
Frequently Asked Questions
Is the event loop part of the JavaScript language itself?
No. The event loop, along with setTimeout, fetch, and the DOM, is provided by the runtime environment (the browser, or Node.js), not by the JavaScript language specification itself. The specification defines how microtasks and the call stack behave, but the overall event loop mechanism is implemented by the environment running the code.
Does Node.js have the same event loop as the browser?
They're conceptually similar but not identical. Node.js has additional queue phases beyond just microtasks and macrotasks, including separate handling for things like file system callbacks and process.nextTick, which runs even before regular microtasks. The core idea, single call stack plus queues, is the same, but the exact phase ordering differs.
Can microtasks ever block the browser from rendering?
Yes. If a microtask keeps scheduling more microtasks indefinitely, the queue never fully drains, and the browser never gets a chance to render a new frame or handle a macrotask, effectively freezing the page. This is rare in practice but is a real way to accidentally create an infinite, unresponsive loop even while using "async" code.
Why does await pause a function but not block the rest of the page?
await only pauses execution of the function it's inside. The call stack is freed up as soon as the await is hit, so other code, other functions, UI updates, event handlers, can run in the meantime. Once the awaited Promise settles, the rest of the function is scheduled as a microtask and resumes from where it left off.
Is there a way to see the queues directly in DevTools?
Not as a direct visual list, but Chrome DevTools' Performance panel records a timeline that shows when tasks and microtasks actually execute, which is the closest practical tool for seeing this behavior on a real page rather than a simplified example.
Conclusion
Once you know there are two queues, not one, and that microtasks always drain completely before the next macrotask, the "weird" ordering of Promises versus setTimeout stops being weird. It's the same small set of rules playing out every time. This is also the piece that makes async/await and careful setTimeout-based code (like debouncing) actually predictable instead of something you just copy and hope works.