jsguides

Understanding the JavaScript Event Loop

The event loop is the heartbeat of asynchronous JavaScript. Understanding JavaScript scheduling determines when your callbacks run, when promises resolve, and how your code executes in sequence despite JavaScript being single-threaded.

What is the event loop?

JavaScript runs on a single thread. It can only do one thing at a time. Yet you can write code that feels asynchronous, handling user clicks while downloading files and updating UI while fetching data.

This works because JavaScript separates the execution (what happens on the thread) from the scheduling (when things happen).

The event loop constantly checks if the call stack is empty. If it is, it takes the next task from a queue and runs it. That queue is the bridge between asynchronous operations and your code.

The three key concepts

The call stack

The call stack is where JavaScript tracks function execution. When you call a function, it gets pushed onto the stack. When it returns, it pops off.

function greet() {
  console.log("Hello");
}

function sayHi() {
  greet();
}

sayHi();

This pushes sayHi, then greet, then logs, then each pops off in reverse order. The stack processes synchronously. One function must finish before the next begins.

Task queue (macrotasks)

The task queue holds callbacks from setTimeout, setInterval, I/O operations, and UI rendering. When the call stack empties, the event loop takes the first task from this queue and runs it.

console.log("Start");

setTimeout(() => {
  console.log("Timeout callback");
}, 0);

console.log("End");

The setTimeout schedules the callback, but the callback doesn’t run inline. It waits in the task queue while the remaining synchronous code finishes. Here is the actual output order, confirming that the timeout callback runs last despite the 0ms delay:

Output:
Start
End
Timeout callback

Even with a 0ms delay, the callback runs after the synchronous code finishes. The timeout goes off, but its callback waits in the task queue until the stack clears.

Microtask Queue

Promises, queueMicrotask, and mutation observers go into the microtask queue. This queue has priority over the task queue.

console.log("Start");

Promise.resolve().then(() => {
  console.log("Promise resolved");
});

setTimeout(() => {
  console.log("Timeout");
}, 0);

console.log("End");

This example places both a promise callback and a timeout on their respective queues. The output reveals that microtasks always run before the next macrotask, regardless of the order in which they were scheduled. The promise resolution fires before the timeout callback even though the timeout was created first:

Output:
Start
End
Promise resolved
Timeout

The promise callback runs before the timeout callback because microtasks always execute after the current synchronous code but before any new task queue tasks. This priority system is the key to understanding why some async code appears to run out of order.

The order of execution

Here is the complete sequence:

  1. Run all synchronous code until the stack is empty
  2. Run all microtasks until the queue is empty
  3. Render any UI updates
  4. Take the first task from the task queue and run it
  5. Repeat
console.log("1. Synchronous");

setTimeout(() => console.log("2. Task queue"), 0);

Promise.resolve().then(() => console.log("3. Microtask"));

console.log("4. More sync");

The code schedules items on both queues within a single synchronous block. The output confirms the sequence described above: all sync code runs first, then all microtasks drain completely, and only then does the task queue callback fire. This ordering is guaranteed by the specification:

Output:
// 1. Synchronous
// 4. More sync
// 3. Microtask
// 2. Task queue

Practical Example

Understanding this order matters when mixing timers and promises in real code. The function below creates two setTimeout callbacks and a chained promise to show how microtasks squeeze between macrotasks. Reading the output order helps you predict what happens when you combine several async operations:

function demo() {
  console.log("1. Start");

  setTimeout(() => console.log("2. Timeout 1"), 0);

  Promise.resolve()
    .then(() => {
      console.log("3. Promise 1");
      return Promise.resolve();
    })
    .then(() => console.log("4. Promise 2"));

  setTimeout(() => console.log("5. Timeout 2"), 0);

  console.log("6. End");
}

demo();

Notice that both timeouts fire after both promise callbacks, even though they were interleaved in the source. The microtask queue drains completely before the event loop picks up the next task queue item, so promise chains always finish before the next timeout fires:

Output order:
// 1. Start
// 6. End
// 3. Promise 1
// 4. Promise 2
// 2. Timeout 1
// 5. Timeout 2

Common Misconceptions

”setTimeout with 0ms runs immediately”

It doesn’t. It schedules the callback for the next iteration of the event loop, after all synchronous code and all microtasks complete.

”Promises run in parallel”

They don’t. A promise callback runs asynchronously but one at a time. The microtask queue processes sequentially.

”async/await creates new threads”

It doesn’t. async/await is syntactic sugar over promises. The function pauses at await, yields to the event loop, and resumes when the promise resolves.

async function example() {
  console.log("1. Start");
  await Promise.resolve();
  console.log("2. After await");
  console.log("3. Also after await");
}

The two lines after await are both microtasks. They run in order before any task queue callbacks.

Browser vs Node.js

The event loop works similarly in browsers and Node.js, but there are differences.

In browsers, rendering happens between task queue callbacks. This is why long-running JavaScript blocks the UI because the browser can’t render until your code yields.

Node.js doesn’t have a built-in renderer, so it can process tasks more continuously. However, libuv (the underlying library) handles async I/O through a thread pool for file system operations.

queueMicrotask

You can add your own callbacks to the microtask queue using queueMicrotask:

queueMicrotask(() => {
  console.log("This runs as a microtask");
});

This is useful when you need your callback to run before the browser renders but after synchronous code completes. It gives you a slot between the current synchronous execution and the next paint cycle.

Why this matters

If you don’t understand the event loop, you might accidentally block the UI by running a long synchronous loop on the main thread. The browser cannot render or handle input until the loop finishes. Yielding periodically keeps the page responsive:

// BAD: Blocks the thread
function processLargeArray(arr) {
  for (let item of arr) {
    heavyComputation(item);
  }
}

// GOOD: Yields to the event loop
async function processLargeArray(arr) {
  for (let item of arr) {
    heavyComputation(item);
    await Promise.resolve(); // Yield to UI
  }
}

The second version lets the browser render, handle clicks, and process other callbacks between iterations.

A practical rule for timing

If you are not sure where a piece of work belongs, ask whether it must happen before the browser can respond or whether it can wait until the current turn is finished. That question often tells you whether to keep the work synchronous, send it to a microtask, schedule a task, or move it to a worker. The right choice is usually the one that protects responsiveness while keeping the code easy to follow.

That rule also helps in tests and debugging. When a log order looks strange, the queue the work came from is often the key. When a UI update feels late, the answer is often that too much is happening before the browser gets a chance to paint. A simple timing rule can keep both the implementation and the test suite easier to reason about.

Putting the event loop to work

Once you understand the event loop, the practical goal is to keep the main thread responsive. That usually means breaking large tasks into smaller chunks, choosing async boundaries carefully, and avoiding long loops that block rendering. The browser does not need every task to finish instantly. It needs the page to stay interactive while work is happening in the background or in smaller pieces.

Promises and microtasks are powerful, but they can also be overused. If you keep scheduling more microtasks without letting the browser breathe, the UI can still feel stuck even though you are not using a blocking loop. That is why it helps to think about the size of the work, not just the syntax you use. Sometimes a timer or a worker is a better fit than another promise chain.

Debugging timing bugs usually starts with understanding order, not with adding more code. If something runs too soon or too late, trace whether it came from the stack, a microtask, or a task queue callback. That mental model is often enough to explain a weird log order or a stale UI update. The more you practice it, the faster you can spot code that needs to yield earlier.

This also matters when you build tests. A test that assumes synchronous completion can accidentally miss the real timing of the code under test. Waiting for the correct queue, using a timer helper, or asserting after the promise chain settles gives you a more realistic check. That keeps the test aligned with how the browser or Node actually runs the code.

In larger apps, the event loop is not just background theory. It shapes how loading indicators, streaming updates, debounced input, and worker messages feel to the user. When you design with that in mind, your code tends to look calmer and the interface tends to feel more responsive.

See Also