How the
Event Loop Works? // runtime: browser
Imagine a loop with a single rule: only one cursor can orbit it at a time. No skipping ahead, no parallel paths. Just one cursor, one loop, one cycle at a time.
That is JavaScript. It is a single-threaded language. It has one call stack, one thread of execution, and it runs your code line by line, in the order it appears. While it is doing one thing, it literally cannot do anything else.
This sounds limiting, right? And it is! If your cursor stops mid-loop to wait at a station, the entire cycle grinds to a halt. No rendering, no click handlers, no animations. The browser tab freezes. Users rage.
// This blocks EVERYTHING for ~3 seconds
while (Date.now() < Date.now() + 3000) {
// The cursor is stuck. Nothing else can run.
}But here is the thing: this constraint is also a superpower. Because JavaScript only does one thing at a time, your code is predictable. You never have to worry about two functions fighting over the same variable, or a callback sneaking in mid-execution. The order is deterministic.
The question is: if we only have one cursor, how does JavaScript handle timers, network requests, and all the other async stuff the web demands? That is what the rest of this guide is about. Let us follow the cursor around the loop.
The Call Stack
The call stack is our cursor's GPS. It tracks exactly where the cursor is at any moment: which function is running, and which functions are waiting for it to return.
Every time you call a function, it gets pushed onto the stack. When the function finishes and returns a value, it gets popped off. The cursor always executes whatever is sitting on top.
function greet(name) { return `Hello, ${name}!`;} function welcome() { const message = greet("world"); console.log(message);} welcome();Walk through this step by step:
welcome()is called — pushed onto the stack- Inside
welcome, we callgreet("world")— pushed on top greetreturns"Hello, world!"— popped offconsole.log(message)is pushed onto the stackconsole.logreturns — popped offwelcomefinishes — popped off. The stack is empty.
The critical rule: the cursor cannot move forward until the current function on top of the stack finishes. If you call a function that calls a function that calls a function, they all pile up. The cursor does not come back to the main loop until every nested call has returned.
This is synchronous execution. Predictable. Orderly. One thing at a time, like a cursor orbiting the loop through every station. But what happens when our cursor needs something that takes a while, like fetching data from across the internet?
Web APIs
Here is where our event loop metaphor gets interesting. The cursor cannot leave its loop, but the environment has workers who can do work in the background.
When you call setTimeout, fetch, or add an event listener, you are not running JavaScript. You are sending a request to the browser's built-in features — the Web APIs. These are the background workers. They work off-loop, on their own threads, while the cursor keeps orbiting.
console.log("Start"); setTimeout(() => { console.log("Timer done");}, 1000); console.log("End");Here is what actually happens:
console.log("Start")is pushed onto the call stack — it runs immediately- It returns — popped off the stack
setTimeoutis called — it hands the callback and 1000ms delay to the browser's timer APIsetTimeoutreturns immediately — popped off. The browser is now counting down in the backgroundconsole.log("End")is pushed onto the stack and runs- It returns — popped off. All synchronous code is done, but the timer callback is still waiting...
setTimeout is not a JavaScript function in any meaningful sense. It is a label, a facade, for browser functionality that lives entirely outside the JavaScript engine. Same with fetch (network requests), document (the DOM), and even console (dev tools).
The JavaScript engine is just the cursor. The browser is the entire runtime organization: the background workers, the timer system, the network layer, the telemetry. JavaScript gets to use all of it through these facade functions, but the work happens elsewhere.
Here is a distinction worth internalizing: the JavaScript engine itself is single-threaded, but the environment it lives in is not. The call stack belongs to the engine — that is the cursor. But the event loop, the queues, the timers, the network layer? Those belong to the environment. The browser is a multi-threaded runtime wrapping a single-threaded language. JavaScript does not "do" async. The environment does async on its behalf.
So when the timer finishes, where does the callback go? It cannot just jump onto the call stack. That would break our "one cursor, one loop" rule. It needs to wait its turn.
The Callback Queue
When a timer finishes or a click happens, the browser does not slam the callback onto the call stack. That would be chaos — imagine a background worker cutting into the cursor's orbit. Instead, the callback goes into a waiting area: the callback queue.
Think of it as a staging station. Callbacks line up, single file, waiting for their chance to enter the loop. And the event loop — our loop marshal — enforces one strict rule: one task per cycle.
setTimeout(() => console.log("A"), 1000);setTimeout(() => console.log("B"), 3000);console.log("C");Even though A's timer is shorter, neither callback runs until all synchronous code finishes. Here is the full sequence:
setTimeout(A)is called — hands callback A to the browser's timer (1000ms)- It returns — popped off. The browser starts counting down
setTimeout(B)is called — hands callback B to the browser's timer (3000ms)- It returns — popped off. Both timers are now ticking in the background
console.log("C")is pushed onto the stack — runs synchronously, printsC- It returns — popped off. All synchronous code is done.
AandBare still waiting for their timers to expire
The callback queue does not run until all global code has finished. You could have a million console.log statements after setTimeout(fn, 0), and every single one would execute before that callback gets its chance. The event loop is strict. Predictable. No exceptions.
We could have a million console logs, and still, everything would be left until after. That is not a bug. That is the design. Predictability over speed, every time.
The Microtask Queue
Now here is the plot twist. There is not just one staging station. There are two. And one of them has priority access.
When you use Promises — .then(), .catch(), or async/await — the callbacks do not go into the regular callback queue. They go into a separate, higher-priority lane called the microtask queue.
The difference? The event loop drains all microtasks before it does anything else. Not one per cycle. All of them.
setTimeout(() => console.log("Task"), 2000); fetch("/api/starwars") .then(res => res.json()) .then(data => console.log(data.name)); console.log("Sync");Even though setTimeout was called first, the fetch callback wins. Every time. Here is why:
setTimeoutis called — hands its callback to the browser's timer (2000ms). It will enter the callback queue- It returns — popped off
fetch("/api/starwars")is called — sends a network request via the browser. Its.then()will enter the microtask queue when the response arrivesfetchreturns a Promise immediately — popped off. The request is now in-flightconsole.log("Sync")is pushed onto the stack and runs- It returns — popped off. Now the event loop checks the microtask queue first, then the callback queue
Notice something subtle: the fetch() call itself fires immediately when the interpreter hits that line. It does not wait for the rest of your synchronous code to finish. The browser's network thread starts the HTTP request right away, in parallel. What does wait is the .then() callback — that only runs after the fetch resolves and the call stack is empty. So the request is already in-flight while console.log("Sync") executes. The fetch() function is synchronous; the result handling is asynchronous.
Microtasks run whenever the JavaScript stack empties. Not just between tasks — after every task, after every callback, after every event handler. The engine clears the entire microtask queue before moving on.
And here is the dangerous part: if a microtask queues another microtask, that one runs too, before any task or rendering gets a chance. Microtasks can starve everything else:
// if you ever wanted to freeze your browser with microtasks, here's the recipe
function forever() {
Promise.resolve().then(forever);
}
forever();The cursor never completes its cycle. The priority station has an infinite queue. The loop marshal keeps sending the cursor through it, and the loop never gets to repaint.
Rendering
After the cursor finishes its tasks and clears the microtask queue, the loop marshal checks one more thing: does the screen need to be repainted?
This is the rendering step. The browser may run these sub-steps:
- requestAnimationFrame callbacks — your chance to update animations
- Style calculation — computing which CSS rules apply
- Layout — calculating where everything goes on the page
- Paint — actually drawing pixels to the screen
The key word there is may. The browser is smart. If nothing visual has changed, it skips the entire rendering step. No need to repaint a screen that looks the same. This typically runs at 60 times per second (every ~16.6ms), synced to your monitor's refresh rate.
requestAnimationFrame(() => { document.body.style.background = "blue";}); setTimeout(() => console.log("Task"), 1000); fetch("/api/starwars") .then(res => console.log(res.json()));This is why long-running tasks cause jank. If your JavaScript takes 200ms to run, the browser cannot render for that entire duration. The user sees a frozen screen, unresponsive buttons, choppy animations. The cursor is hogging the loop and the paint crew cannot get in to refresh the visuals.
requestAnimationFrame is synchronized with the display's refresh rate, while setTimeout is not. A setTimeout loop might fire too often (wasting CPU) or not often enough (causing visual stutters). For animations, always use requestAnimationFrame — it is the browser telling you "now is a good time to update visuals."
The rendering step is the final piece of the cycle. After it completes (or gets skipped), the event loop starts a brand new cycle — or as it is often called, a new tick. Each tick is one full pass: check the callback queue, drain microtasks, maybe render. Around and around, forever, until you close the tab.