Skip to content

How the JavaScript 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:

  1. welcome() is called — pushed onto the stack
  2. Inside welcome, we call greet("world") — pushed on top
  3. greet returns "Hello, world!" — popped off
  4. console.log(message) is pushed onto the stack
  5. console.log returns — popped off
  6. welcome finishes — 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 browser services that 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 browser services. 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:

  1. console.log("Start") is pushed onto the call stack — it runs immediately
  2. It returns — popped off the stack
  3. setTimeout is called — it hands the callback and 1000ms delay to the browser's timer API
  4. setTimeout returns immediately — popped off. The browser is now counting down in the background
  5. console.log("End") is pushed onto the stack and runs
  6. It returns — popped off. All synchronous code is done, but the timer callback is still waiting...

setTimeout is not part of the JavaScript language itself — it is provided by the browser. When you call it, the JS engine hands the callback and delay to the browser's timer system and moves on. Same with fetch, document, and even console — they are all browser APIs that JavaScript gets to use, but the heavy lifting happens outside the engine.

The JavaScript engine is just the cursor. The browser is the entire runtime organization: the browser services, 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 Task 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 browser service cutting into the cursor's orbit. Instead, the callback goes into a waiting area: the task queue.

A note on naming: the spec calls this the task queue. You will see tutorials call it the "callback queue" or "macrotask queue" — those are informal names that stuck. They all refer to the same thing: the queue where setTimeout, setInterval, and event handler callbacks wait their turn.

Think of it as a staging station. Tasks 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.

Here is something most tutorials skip: the script you write is itself a task. When the browser first loads your <script>, it schedules that entire script as one task. Every line of synchronous code — every variable declaration, every function call — runs as part of that single task. Nothing else can enter the call stack until the script finishes.

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:

  1. setTimeout(A) is called — hands callback A to the browser's timer (1000ms)
  2. It returns — popped off. The browser starts counting down
  3. setTimeout(B) is called — hands callback B to the browser's timer (3000ms)
  4. It returns — popped off. Both timers are now ticking in the background
  5. console.log("C") is pushed onto the stack — runs synchronously, prints C
  6. It returns — popped off. All synchronous code is done. A and B are still waiting for their timers to expire

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 spec defines each event loop iteration precisely: "During each iteration, it runs at most one pending JavaScript task, then any pending microtasks, then performs any needed rendering and painting before looping again." Only that one already-pending task gets precedence. After it executes, microtasks completely dominate — they all drain before the next task is even looked at.

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 task queue. They go into a separate, higher-priority lane called the microtask queue.

The difference? After each task executes, the event loop drains all microtasks before moving on. Not one per cycle. All of them — including any new ones added during the drain.

setTimeout(() => console.log("Task"), 2000);
 
fetch("/api/starwars")
.then(res => res.json())
.then(data => console.log(data.name));
 
console.log("Sync");
· · ·
  1. setTimeout is called — hands its callback to the browser's timer (2000ms). It will enter the task queue
  2. It returns — popped off
  3. fetch("/api/starwars") is called — sends a network request via the browser. Its .then() will enter the microtask queue when the response arrives
  4. fetch returns a Promise immediately — popped off. The request is now in-flight
  5. console.log("Sync") is pushed onto the stack and runs
  6. It returns — popped off. The initial script task is done. The cursor resumes orbiting — both queues are still empty because the fetch is in-flight and the timer is counting down. When the fetch resolves, its .then() enters the microtask queue and gets drained. Later, when the 2000ms timer fires, its callback enters the task 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:

  1. requestAnimationFrame callbacks — your chance to update animations
  2. Style calculation — computing which CSS rules apply
  3. Layout — calculating where everything goes on the page
  4. 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 = "red";
});
 
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: run one task, drain the microtask queue, maybe render. Around and around, forever, until you close the tab.

The event loop is what lets JavaScript punch above its weight. It is a single-threaded language — it can only do one thing at a time — yet it handles timers, network requests, and user interactions without breaking a sweat. The event loop is the trick behind it: a simple, endless cycle that keeps checking "is there anything to do next?" and makes sure nothing gets forgotten.