DEV Community

Anshu Pathak
Anshu Pathak

Posted on Originally published at zyvop.com

The JavaScript Event Loop: Why Your Code Doesn't Run the Way You Think

If you've ever stared at a setTimeout(fn, 0) and wondered why it doesn't run immediately, or been surprised that a Promise resolves before a timer even though the timer was scheduled first, you've met the event loop. It's one of those concepts every JavaScript developer eventually has to confront — usually the hard way: a production bug, a failed interview question, or a late night with the debugger open.

This post breaks down what the event loop actually is, the pieces that make it up, and why understanding it will change how you write asynchronous code.

JavaScript Is Single-Threaded (Mostly)

JavaScript runs on a single thread. One line of code executes, then the next, then the next. There's no true parallelism inside your JS code itself — only ever one thing happening at a time.

That raises an obvious question: how does JavaScript handle things like network requests, file reads, or timers without freezing the entire page while it waits? The answer isn't inside the JavaScript engine at all. It's the surrounding environment — the browser or Node.js — that does the waiting, while the event loop coordinates handing work back to your single thread at the right moment.

The Four Pieces You Need to Know

1. The Call Stack This is where your code actually executes. Every function call gets pushed onto the stack, and every return pops it off. If a function calls another function, that new function goes on top. This is standard, synchronous execution, and it behaves exactly like the call stack in any other language.

2. Web APIs (or Node APIs) Things like setTimeout, fetch, and file system operations aren't part of the JavaScript language itself — they're provided by the runtime (the browser or Node). When you call one of these, the runtime takes over the waiting, freeing up the call stack to keep executing other code.

3. The Callback Queue (a.k.a. Macrotask Queue) Once a Web API finishes its work — say, a timer expires — it doesn't jump straight back into your running code. It places its callback into a queue, waiting for its turn.

4. The Microtask Queue Promises use a separate, higher-priority queue. Anything scheduled with .then(), .catch(), .finally(), or async/await goes here instead of the regular callback queue.

The Event Loop's One Job

The event loop constantly asks one question: is the call stack empty?

When the answer is yes, it checks the microtask queue first and runs everything there until it's completely empty — even if new microtasks get added along the way. Only after the microtask queue is fully drained does it pull a single task from the callback queue and push it onto the stack.

Then it repeats. Forever.

This priority ordering — microtasks fully drained before every single macrotask — is the single most important thing to internalize. It explains almost every "surprising" async behavior you'll encounter.

Seeing It In Action

Try predicting the output of this before reading the answer:

console.log('Start');

setTimeout(() => console.log('Timeout'), 0);

Promise.resolve()
  .then(() => console.log('Promise 1'))
  .then(() => console.log('Promise 2'));

console.log('End');

Enter fullscreen mode Exit fullscreen mode

The output is:

Start
End
Promise 1
Promise 2
Timeout

Enter fullscreen mode Exit fullscreen mode

Here's why: console.log('Start') and console.log('End') run synchronously, so they fire immediately, before anything async gets a chance. The setTimeout callback gets handed to the Web API layer, and even with a 0ms delay, it still has to wait its turn in the callback queue. Meanwhile, the promise chain queues its callbacks as microtasks, which get priority over the callback queue. So both .then() callbacks run before the timeout ever gets a chance — even though the timeout was scheduled first.

A Simplified Picture

Mermaid Diagram

Node.js implements a more elaborate version of this, with additional phases for timers, I/O callbacks, and close callbacks — but the browser model above covers the core mental model that trips up most developers, regardless of runtime.

Why This Actually Matters

This isn't just trivia for interview questions (though it shows up there constantly). Understanding the event loop helps with real problems:

  • Debugging race conditions. If two async operations resolve in an unexpected order, queue priority is usually the reason.

  • Avoiding UI jank. Long synchronous blocks of code hog the call stack and block the event loop from processing anything else, including user clicks and re-renders.

  • Writing correct async/await code. await doesn't pause the whole runtime — it pauses the current function and lets the event loop keep the rest of the app running. Knowing this helps you reason about when your awaited code will actually resume relative to everything else happening.

  • Reasoning about Promise.all vs. sequential await. Once you see promises as microtasks competing for the same queue, it's much easier to predict how concurrent operations interleave.

A Common Gotcha: Starving the Callback Queue

Because microtasks are drained completely before a single macrotask runs, it's possible to accidentally starve the callback queue. If a promise's .then() keeps scheduling more microtasks, the event loop can get stuck processing them indefinitely — delaying timers, UI updates, and I/O callbacks. This is rare, but it's a real production issue, and a good reason to be thoughtful about chaining large numbers of promises recursively.

The Takeaway

The event loop isn't magic — it's a small, deterministic set of rules: run synchronous code, drain microtasks completely, then take one macrotask, and repeat. Once that model is in your head, "unexpected" async behavior stops being unexpected. You start reading async code the way the engine actually executes it, which is a genuinely useful skill whether you're debugging a stuck UI or explaining to a teammate why their timeout callback ran later than they expected.

Next time setTimeout(fn, 0) doesn't run immediately, you'll know exactly why.


Originally published on ZyVOP

💡 For more articles like this, subscribe to the ZyVOP newsletter!

Top comments (0)