How the JavaScript Event Loop Actually Works
Photo by Florian Olivo on Unsplash
Table of Contents
The Week I Couldn't Sleep Because of Callbacks
The data arrived in the wrong order. Sometimes. Not always. It worked when I ran it slowly with console.log sprinkled everywhere, but broke the moment I cleaned up the logs and ran it properly. Classic.
That's when I realized I didn't actually understand how JavaScript runs code. I knew callbacks existed. I'd used setTimeout. I'd even shipped a few async/await functions without fully grasping why they needed the `async` keyword. But I didn't have a mental model.
Once I finally understood the event loop — really understood it, not just memorized the definition — everything snapped into place. The weird timing bugs, the confusing callback ordering, the reason `Promise.then()` runs before a `setTimeout` at zero milliseconds. All of it made sense.
Here's the mental model I wish someone had handed me on day one.
JavaScript Is Single-Threaded — Here's What That Actually Means
This sounds limiting. How does it handle network requests, timers, and user events without freezing the whole page?
The answer is the event loop — and it works because JavaScript doesn't handle those things directly. The browser (or Node.js runtime) handles them, and JavaScript just picks up the results when it's ready.
Think of it like a restaurant. The JavaScript thread is your single waiter. When a table orders food, the waiter doesn't stand in the kitchen waiting for it. They hand the order to the kitchen (the runtime), go take other orders, and come back when the kitchen rings the bell. The "bell" is the event loop telling the call stack that a result is ready.
The single thread is the call stack. The kitchen is the Web API or Node API environment. And the "bell" mechanism is a combination of queues and the event loop itself.
One more way to think about it: the event loop is the reason JavaScript can run in a browser without blocking the page while waiting for a server response. It's not magic -- it's a deliberate design choice that offloads waiting to the environment while keeping the language itself simple and predictable to reason about.
The Four Components You Need to Know
Photo by Chris Ried on Unsplash
1. The Call Stack This is where your code actually runs. When you call a function, it gets pushed onto the stack. When it returns, it gets popped off. It's a last-in, first-out structure. If a function calls another function, the new one sits on top. The engine works through them top-down.
When the stack is empty, JavaScript has nothing to do — it's waiting.
2. Web APIs (or Node APIs) These handle async operations that the JavaScript engine itself doesn't run: `setTimeout`, `fetch`, DOM events, `setInterval`. When you call `setTimeout(fn, 1000)`, you're handing the timer off to the browser. JavaScript keeps running. The browser counts the milliseconds independently.
3. The Task Queue (Macrotask Queue) When a Web API finishes — the timer expires, the fetch completes, the click event fires — the callback doesn't go straight onto the call stack. It goes into the task queue. It waits there until the call stack is empty.
4. The Microtask Queue Promise callbacks (`.then()`, `.catch()`, `.finally()`) and `queueMicrotask()` go here instead of the task queue. The microtask queue has priority — it gets fully drained before the event loop picks up the next task from the task queue.
That's why this runs in a specific order: ```javascript console.log('1'); setTimeout(() => console.log('3'), 0); Promise.resolve().then(() => console.log('2')); console.log('4'); // Output: 1, 4, 2, 3 ``` 1 and 4 are synchronous (call stack). 2 is a Promise microtask (runs before setTimeout even at 0ms). 3 is a setTimeout macrotask (runs last).
I tested this dozens of times when I first learned it because I couldn't believe the order was that predictable. It is.
How the Event Loop Actually Works Step by Step
Here's the order it follows, every tick: 1. Run all synchronous code until the call stack is empty 2. Drain the microtask queue completely (every `.then()`, every resolved Promise) 3. Render updates if needed (browser-only) 4. Pick one task from the task queue and run it 5. Go back to step 1
Notice that step 2 drains the *entire* microtask queue. If a microtask adds another microtask, that new one also runs before any task queue items. This is why you can theoretically starve the task queue with infinite Promise chains (don't do that).
Here's a practical example I've been using to explain this to teammates:
```javascript async function fetchUser() { const res = await fetch('/api/user'); // hands off to Web API const data = await res.json(); // another async op return data; }
fetchUser().then(user => console.log(user)); console.log('This runs before fetchUser resolves'); ```
`await` is syntactic sugar for `.then()`. When the engine hits `await fetch(...)`, it suspends the `fetchUser` function, returns control to the caller, and the log runs synchronously. When the fetch resolves, the continuation of `fetchUser` is queued as a microtask. It resumes after the current synchronous code finishes.
If you've ever used `async/await` and wondered why the line after an `await` doesn't run immediately — now you know. It's queued as a microtask and runs on the next event loop pass.
Common Bugs That Make Sense Now
The classic loop bug: ```javascript for (var i = 0; i < 3; i++) { setTimeout(() => console.log(i), 100); } // Logs: 3, 3, 3 — not 0, 1, 2 ``` By the time the setTimeout callbacks run, the for loop has finished and `i` is 3. The `var` is function-scoped, not block-scoped. The fix is `let` (block-scoped, one closure per iteration) or wrapping in an IIFE.
Long synchronous operations block everything: ```javascript function doHeavyWork() { const start = Date.now(); while (Date.now() - start < 3000) {} // blocks for 3s } doHeavyWork(); // The UI is completely frozen for 3 seconds ``` Because the call stack is occupied, the event loop can't process anything — not click events, not timer callbacks, nothing. This is why heavy computation belongs in a Web Worker.
Unhandled Promise rejections: If you forget `.catch()` on a Promise, the rejection goes into the microtask queue and surfaces as an unhandled rejection error. In older Node versions this would silently fail. Now it crashes the process (or logs a warning). Always handle rejections — either with `.catch()` or a `try/catch` inside an async function.
setTimeout with 0ms isn't actually 0ms: The HTML spec sets a minimum of 4ms for nested timers. And even at 0ms, the callback still goes through the task queue, so it runs after all synchronous code and all microtasks. It's not "run immediately after this line" — it's "run as soon as the stack is empty and the microtask queue is clear."
I actually keep a hash generator and URL encoder open in browser tabs when debugging API stuff — not related to the event loop specifically, but they save me from switching contexts constantly when I'm deep in async debugging. Small wins add up.
async/await vs Promises vs Callbacks — What to Use
Callbacks are the original async pattern. They work, but they nest badly ("callback hell") and error handling is inconsistent. You'll still see them in older Node APIs like `fs.readFile(path, (err, data) => {})`. I covered the full Promises vs async/await tradeoffs in depth in the post on async/await vs Promises in JavaScript — worth reading if you want to understand when each shines.
Promises cleaned up callbacks with a chainable `.then()/.catch()` API. Better, but still verbose for complex chains.
async/await is just Promises with nicer syntax. Under the hood, `await` is `.then()`. But it reads like synchronous code, which makes it much easier to follow and debug. Error handling with `try/catch` is also more natural.
The one place I still reach for raw Promises: running multiple async operations concurrently.
```javascript // Sequential — slow (waits for each to finish) const user = await fetchUser(); const posts = await fetchPosts();
// Concurrent — fast (both start at the same time) const [user, posts] = await Promise.all([fetchUser(), fetchPosts()]); ```
`Promise.all` is one of the most useful things in JavaScript and it's underused. If your requests don't depend on each other, run them in parallel. `Promise.allSettled` is the variant that waits for all to settle even if some reject — useful when you want all results even if some fail.
Understanding the event loop makes all of this click. The requests don't actually run in parallel — JavaScript is still single-threaded — but the Web API handles both fetch calls concurrently, and their results land in the microtask queue when ready. Promise.all just waits until both are there before resolving.
Frequently Asked Questions
Why does JavaScript only have one thread?
JavaScript was originally designed for simple browser scripting where multi-threading would create complex DOM synchronization problems. A single-threaded model is simpler to reason about and avoids race conditions when manipulating the DOM. The event loop handles async operations without true parallelism by delegating blocking work to browser or Node.js APIs.
What's the difference between the microtask queue and the task queue?
The microtask queue holds Promise callbacks (.then, .catch, .finally) and queueMicrotask() calls. The task queue holds setTimeout, setInterval, and DOM event callbacks. The key difference: the microtask queue is fully drained after every task before the event loop picks the next task. This means Promise callbacks always run before setTimeout callbacks, even at 0ms.
Does async/await block the thread?
No. When an async function hits an await, it suspends and yields control back to the event loop. Other code can run while the awaited Promise is pending. The async function resumes as a microtask when the Promise resolves. Only synchronous code inside async functions blocks the thread — the await itself does not.
What causes 'Maximum call stack size exceeded'?
This error means you've overflowed the call stack, usually from infinite recursion (a function calling itself without a base case). Every function call pushes a frame onto the stack. When you recurse too deeply, the stack runs out of space. Fix it by adding a proper base case, or use an iterative approach with a loop for deeply recursive algorithms.
What is a 'blocking operation' in JavaScript?
A blocking operation is synchronous code that occupies the call stack for a long time, preventing the event loop from processing other work. Examples include long while loops, large [JSON.parse() calls on payloads you should validate with a JSON formatter](/tools/json-formatter), and CPU-intensive calculations. During a block, the UI freezes and no event callbacks can run. Move heavy work to Web Workers or break it into chunks with setTimeout to keep the event loop free.
How does Node.js handle async without a browser?
Node.js has its own event loop built on the libuv library, which provides async I/O for file system, network, and other operations. Instead of browser Web APIs, Node has its own equivalent: the fs module, net module, etc. The event loop concept is the same — callbacks go through a queue and run when the call stack is clear — but Node's event loop has additional phases for different I/O types.
Try ToolsFuel
23+ free online tools for developers, designers, and everyone. No signup required.
Browse All Tools