JavaScript Event Loop Explained: The Complete Guide
Learn how the JavaScript event loop really works — call stack, microtasks, and macrotasks — with worked examples, edge cases, and a cheat sheet.

You've written setTimeout(fn, 0) expecting it to run "immediately." It didn't. A Promise.then() you scheduled a line later ran first, and somewhere a for loop of 50,000 iterations froze your UI for a full second despite every function being "async." None of this is a bug. It's the JavaScript event loop doing exactly what it always does — you just haven't seen the mechanism yet.
By the end of this guide you'll be able to:
- Explain, precisely, why microtasks (Promises) always run before macrotasks (
setTimeout,setInterval) — even at a zero delay - Predict the exact console output order of any mix of synchronous code,
setTimeout, andawait - Diagnose a frozen UI as a blocked call stack, not a "slow async function"
- Choose correctly between
queueMicrotask,setTimeout(fn, 0), andrequestAnimationFramefor a given timing need - Avoid the two most common event-loop bugs: microtask starvation and accidental serial
awaits in a loop
Who this is for: you've written async/await and used setTimeout, but you want the model that makes their interaction predictable instead of memorized.
- Why the JavaScript event loop exists
- The mental model
- Stage 1: the call stack and blocking code
- Stage 2: Web APIs and the macrotask queue
- Stage 3: Promises and the microtask queue
- Stage 4: async/await is sugar, not magic
- Stage 5: rendering, and Node's extra queues
- Edge cases and gotchas
- Best practices
- FAQ
- Cheat sheet
- Key takeaways
JavaScript runs on a single thread. One call stack, one thing executing at a time, no parallel function calls in the same realm. That's a deliberate design — it means you never need locks or mutexes to protect a shared variable — but it creates an obvious problem: how does a single-threaded language do anything concurrent, like waiting on a network response, without freezing the entire page while it waits?
Here's the naive expectation, and why it would be a disaster if JavaScript worked this way:
If fetch blocked the thread the way readFileSync blocks in a script, every click, scroll, and animation would freeze for the entire round trip — and that's just one request among the images, timers, and user interactions a real page juggles at once.
The event loop is the fix. It lets the single thread hand off slow work (timers, network calls, I/O) to the browser or Node runtime, keep executing other code meanwhile, and come back to handle the result when it's ready — all without ever running two pieces of your JavaScript at the same time. Concurrency without threads.
The mental model: there is one call stack that must be empty before the event loop will pull anything new into it, and two queues feeding that stack — a microtask queue that always drains completely before the next macrotask, and a macrotask queue that runs one task per turn of the loop.
Picture four lanes:
- The call stack. Where your code actually executes, one frame at a time, top to bottom. As long as there's a frame on it, nothing else runs — not a timer, not a promise callback, not a click handler.
- Web APIs / runtime APIs. Things the JavaScript engine doesn't implement itself —
setTimeout,fetch, DOM events, file I/O in Node. You hand them a callback and they run outside the JS thread, on their own schedule. - The macrotask queue (also called the "task queue" or "callback queue"). Where completed timers, I/O callbacks, and UI events line up to be run, one per loop iteration.
- The microtask queue. Where
Promisereactions (.then,.catch,.finally,awaitcontinuations) andqueueMicrotaskcallbacks line up.
The loop's rule, stated precisely: after the currently running script finishes and the call stack is empty, the engine drains the entire microtask queue — including any new microtasks added while draining — before it takes even one macrotask off the queue. Then it renders (in a browser) if needed, runs exactly one macrotask, and repeats.
That one sentence explains every "surprising" ordering you'll see below. It's not a list of rules to memorize — it's one rule, applied consistently.
Before queues matter at all, internalize what "blocking" means. This runs top to bottom, no gaps:
The call stack must fully unwind — every function call return — before the engine even looks at either queue. This is why a heavy synchronous loop freezes everything, including animations and clicks, no matter how many async functions exist elsewhere in your code:
setTimeout doesn't run your callback itself — it registers it with the runtime's timer API and immediately returns, freeing the stack. The runtime waits out the delay, then places the callback in the macrotask queue, where it waits for its turn:
"1" and "3" run synchronously on the call stack. The setTimeout callback is handed to the Web API layer, timed out immediately (0ms), and dropped into the macrotask queue — but it can only run once the current script finishes and the stack is empty, which is after "3" has already printed.
This is where most developers' intuition breaks, because a Promise callback and a setTimeout callback look similar but are scheduled onto different queues with different priority:
"1" and "4" run synchronously. Then the stack is empty — and per the rule above, the engine drains all microtasks before touching the macrotask queue. Promise.resolve().then(...) queued a microtask, so it runs before the setTimeout callback, even though both were scheduled with a "0ms" wait and the setTimeout line came first.
Chained .then()s compound this, because each one queues a new microtask while the microtask queue is still draining:
Runs right in your browser — poke at it and watch the concept react live.
Think it clicked? Take the 9-question quiz →
Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.
async/await doesn't introduce a new scheduling mechanism — it's syntax sugar over Promises and the same microtask queue you just saw. An async function always returns a Promise, and await is equivalent to attaching a .then() and pausing the function's own execution until it resolves — the pause is local to that function, not to the whole program:
Walk it in order: run() executes synchronously until it hits await. "A" logs. The await schedules the rest of the function (console.log("B")) as a microtask and immediately returns control to the caller. "end" logs synchronously. Only then, with the stack empty, does the microtask queue drain and "B" prints.
This has a real performance consequence: awaiting sequentially in a loop serializes work that could run concurrently.
Both are valid async/await, and both compile to the same microtask machinery — the difference is entirely about when you start each Promise, not about await itself. The same ordering rules govern cancellation too: see how AbortController actually cancels an in-flight fetch for what happens to a pending microtask when its Promise is aborted mid-flight.
In a browser, the engine typically renders a new frame between macrotasks, after the microtask queue is empty. That's why requestAnimationFrame feels smoother for visual updates than setTimeout(fn, 16): it's synchronized to the display's actual paint cycle, not an arbitrary timer.
Node.js subdivides the macrotask queue into ordered phases (timers, pending callbacks, poll, check, close callbacks) and adds one more microtask-like queue that runs even before Promise microtasks: process.nextTick. Its callbacks drain completely before the Promise microtask queue on every pass:
The practical takeaway: in Node, process.nextTick jumps the entire queue, which is powerful and easy to abuse (see the starvation gotcha below).
- Microtask starvation. The microtask queue must fully drain before any macrotask runs, so a microtask that keeps scheduling more microtasks (a runaway recursive
.then()chain, or recklessprocess.nextTickrecursion in Node) can prevent timers, I/O, and rendering from ever getting a turn — a real production failure mode, not a theoretical one, and it throws no error. - Unhandled Promise rejections are silent by default. A rejected Promise with no
.catch()doesn't throw synchronously — it fires anunhandledrejectionevent (browser) orunhandledRejection(Node) asynchronously. Wrapawaitintry/catchor attach.catch(); don't rely on rejections to surface the way thrown errors do. awaitinsideArray.prototype.forEachdoesn't wait.forEachdiscards its callback's return value, soawaiting inside it doesn't pause the outer function — every iteration's async work fires with no ordering guarantee. Usefor...ofto run sequentially, orPromise.all(array.map(...))to run concurrently and wait for all.- Node timers aren't identical to the browser's.
setImmediateexists only in Node and runs in the "check" phase, with ordering relative tosetTimeout(fn, 0)that depends on context (top-level vs. inside an I/O callback). Don't port timer-ordering assumptions between the two runtimes without testing. - A blocked stack blocks everything. A synchronous JSON parse of a huge payload or a dense synchronous loop freezes scrolling and input for as long as it runs —
asyncelsewhere in the codebase doesn't help, because the freeze is on the stack, not the queues.
Reach for queueMicrotask when you need a callback to run before the next macrotask without the overhead or semantics of a resolved Promise — rare, mostly library-internal scheduling.
Reach for setTimeout(fn, 0) when you deliberately want to defer past the current microtask queue and yield to the macrotask queue — for example, chunking a long synchronous loop so the UI can repaint and handle input between chunks.
Reach for requestAnimationFrame for anything visual — it's timed to the browser's paint cycle, avoiding both timer jank and wasted work on a hidden tab.
Avoid using timer ordering as a substitute for real synchronization — a setTimeout(fn, 0) "hack" to wait for a DOM update works by accident, not by contract. Use the actual completion signal instead: a Promise, an event, a MutationObserver.
Chunk long synchronous work rather than hoping async will make it non-blocking on its own — async only yields at await points; a tight synchronous loop inside an async function still blocks the stack for its full duration. The same instinct — don't make the user wait on a queue they can't see — is why optimistic UI updates update the screen before the network Promise resolves instead of after.
Because they're scheduled onto different queues, and the event loop drains the microtask queue (Promises) completely before running a single macrotask (timers). This holds regardless of the timer's delay value — even setTimeout(fn, 0) waits behind every pending microtask.
No — await yields control back to the event loop while the awaited Promise is pending, letting other code run. What does block the loop is synchronous code, including synchronous code inside an async function, before it reaches its first await.
The microtask queue holds Promise callbacks and queueMicrotask callbacks and is fully drained after every task, before the next macrotask runs. The macrotask queue holds timers, I/O callbacks, and UI events, and yields exactly one task per loop iteration — leaving room for rendering and other macrotasks in between.
The core rule — stack first, then drain microtasks, then one macrotask — is the same. Node adds its own phase structure (timers, poll, check, and others) plus process.nextTick, which jumps ahead of Promise microtasks with no browser equivalent.
No — JavaScript's event loop is single-threaded by design; that's what makes it safe without locks. True parallelism requires Web Workers (browser) or Worker Threads (Node), which run on separate threads with their own call stacks and communicate via message passing, not shared memory.
| Task | Code | Runs when |
|---|---|---|
| Run after current sync code, before any timer | queueMicrotask(fn) | End of current microtask drain |
| Run after current sync code, before any timer | Promise.resolve().then(fn) | Same queue as above |
| Defer past all pending microtasks | setTimeout(fn, 0) | Next macrotask turn |
| Sync visual updates to paint | requestAnimationFrame(fn) | Just before next repaint |
| Node: jump ahead of everything, including Promises | process.nextTick(fn) | Before the microtask queue, same turn |
| Run N async calls concurrently | await Promise.all(items.map(fn)) | All start immediately, one wait |
| Run N async calls sequentially (rare — usually a bug) | for (const x of items) await fn(x) | Each waits for the previous |
- One call stack, two queues: the stack always runs to empty first, then the entire microtask queue drains, then exactly one macrotask runs, then repeat.
Promisecallbacks andqueueMicrotaskare microtasks;setTimeout,setInterval, and I/O callbacks are macrotasks — microtasks always win the race, regardless of timer delay.awaitis sugar over.then()— it yields at theawaitpoint, but synchronous code before it still blocks the stack like anything else.- A frozen UI means a blocked call stack, not "async code being slow" — find the synchronous culprit, don't add more
awaits hoping it helps. awaitin a loop is sequential by default; usePromise.allwith.map()when the calls don't depend on each other.
You now know why that setTimeout(fn, 0) lost the race, and why a busy while loop can freeze a page full of "non-blocking" async code: the event loop isn't magic, it's one rule about queues, applied consistently. Next time output surprises you, trace it against the rule instead of guessing. What's the strangest ordering bug the event loop has ever handed you? Drop it in the comments — there's a good chance it's stage 3 or stage 5 in disguise.
Thanks for reading! Let's stay connected:
- ⭐ GitHub — follow me and star the projects: github.com/parsajiravand
- 💬 Discord — join the frontend best-practices community: discord.gg/d9KRhuAwQ
- 📸 Instagram — frontend best practices, daily: @bestpractice___
- 💼 LinkedIn — linkedin.com/in/parsa-jiravand
- ✉️ Email (work & contract inquiries): bestpractice2026@gmail.com
Originally published on dev.to