Skip to main content

πŸ”„ JavaScript Event Loop Explained

JavaScript runs on one thread, yet it juggles timers, clicks, and network responses without missing a beat. The event loop is the quiet coordinator that makes this possible. Once you can trace it, async code stops feeling like magic and starts feeling like rules.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Name the four pieces of the runtime β€” call stack, Web APIs, task queue, and microtask queue β€” and describe each one's job
  • Explain how the event loop moves callbacks from the queues onto the call stack
  • Distinguish macrotasks (e.g. setTimeout) from microtasks (e.g. Promise callbacks) and their priority
  • Predict the exact output order of code that mixes synchronous statements, timers, and Promises
  • Apply techniques to avoid blocking the main thread

Estimated Time: 30–40 minutes  β€’  Difficulty: Intermediate

Hands-on: Predict the output of a mixed sync/timeout/Promise program, then verify it.

In This Lesson

The Runtime at a Glance

In the previous lesson you saw that async callbacks run "later." The event loop is how "later" is decided. To understand it, picture the four parts of the JavaScript runtime working together.

🍳 A kitchen analogy. One chef (the call stack) can only cook one dish at a time. Assistants and appliances (the Web APIs) handle slow tasks β€” a timer, an oven β€” in the background. Finished tickets pile up on a rail (the queues). A manager (the event loop) hands the chef the next ticket, but only when the chef's hands are empty.
flowchart TB A[JavaScript Runtime] --> B[Call Stack] A --> C[Web APIs] A --> D[Task Queue] A --> M[Microtask Queue] A --> E[Event Loop] C -->|finished work| D C -->|resolved promises| M M -->|drained first| B D -->|then one task| B E -.->|watches| B E -.->|feeds| B
  • Call stack β€” where your code actually runs, one frame at a time.
  • Web APIs β€” browser features (timers, network, DOM events) that do slow work off the stack.
  • Task queue (macrotasks) β€” finished callbacks from timers, events, etc., waiting their turn.
  • Microtask queue β€” higher-priority callbacks, mainly from Promises.
  • Event loop β€” the loop that feeds queued callbacks onto the stack when it's empty.

The Call Stack

The call stack tracks where you are in the program. When a function is called it's pushed onto the stack; when it returns it's popped off. Because JavaScript is single-threaded, there is exactly one call stack, and only the frame on top is running.

Watching the stack grow and shrink

function multiplyByTwo(num) {
  return num * 2;
}

function calculate(num) {
  return multiplyByTwo(num) + 10;
}

function printResult(num) {
  const result = calculate(num);
  console.log(result);
}

printResult(5); // logs 20
sequenceDiagram participant CS as Call Stack Note over CS: [] empty Note over CS: push printResult(5) Note over CS: push calculate(5) Note over CS: push multiplyByTwo(5) Note over CS: multiplyByTwo returns 10, pop Note over CS: calculate returns 20, pop Note over CS: printResult logs 20, pop Note over CS: [] empty again

This is Last In, First Out (LIFO): the most recently pushed function is the first to finish. It's also why error messages show a "stack trace" β€” it's literally a snapshot of the stack when things went wrong.

⚠️ Stack overflow

If functions keep calling without returning β€” usually runaway recursion with no base case β€” the stack grows past its limit and you get RangeError: Maximum call stack size exceeded.

function crash(n) {
  return crash(n + 1); // never returns β€” no base case
}
crash(1); // RangeError: Maximum call stack size exceeded

Web APIs: The Background Helpers

Plain JavaScript has no ability to "wait two seconds" or "make a network request." Those powers come from the host environment β€” the browser (or Node.js) β€” through Web APIs. When you call one, the work leaves the call stack and runs in the background.

Common Web APIs

  • Timers: setTimeout, setInterval
  • Networking: fetch, XMLHttpRequest
  • DOM events: click, scroll, keydown
  • Others: Geolocation, WebSockets, and many more

What happens when you call one

  1. You hand the Web API a callback (e.g. setTimeout(cb, 2000)).
  2. The call stack keeps running the rest of your code β€” it does not wait.
  3. The Web API does its slow work in the background (counts down the timer).
  4. When done, it places your callback into the appropriate queue β€” not directly onto the stack.
console.log('Start');

setTimeout(function timeoutCallback() {
  console.log('Runs later (after ~2s)');
}, 2000);

console.log('End');

// Output:
// Start
// End
// Runs later (after ~2s)
sequenceDiagram participant CS as Call Stack participant WA as Web APIs participant TQ as Task Queue participant EL as Event Loop Note over CS: log("Start") CS->>WA: setTimeout(cb, 2000) Note over WA: timer counts down Note over CS: log("End") Note over CS: stack empties Note over WA: 2s elapse WA->>TQ: place cb EL->>TQ: stack empty? take cb TQ->>CS: push cb Note over CS: log("Runs later…")

πŸ’‘ The delay is a minimum, not a promise

setTimeout(cb, 2000) means "run cb no sooner than 2000 ms." If the stack is still busy at the 2-second mark, the callback waits in the queue until the stack is free.

Task Queue vs Microtask Queue

Finished callbacks don't run immediately β€” they line up in a queue and are processed First In, First Out (FIFO). Crucially, there are two queues with different priorities.

QueueFilled byPriority
Microtask queue Promise .then/catch/finally, await continuations, queueMicrotask, MutationObserver Higher β€” drained completely after each task
Task queue (macrotasks) setTimeout, setInterval, DOM events, network callbacks Lower β€” one task per loop turn

Priority in action

console.log('Start');

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

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

console.log('End');

// Output:
// Start
// End
// Promise (microtask)
// Timeout (macrotask)

Even though both are "immediate," the Promise's microtask runs before the setTimeout macrotask. That's because after the synchronous code finishes, the event loop drains all microtasks before it will pick up even a single macrotask.

πŸ“– Key Terms

Macrotask: a unit of work from the task queue (a timer callback, an event handler). One runs per loop iteration.

Microtask: a smaller, higher-priority unit (a Promise reaction). The queue is emptied fully before the next macrotask.

The Event Loop Algorithm

The event loop is astonishingly simple for how much it enables. It runs forever, following this cycle:

  1. Run synchronous code until the call stack is empty.
  2. Drain the microtask queue completely β€” run every microtask, including any new microtasks they schedule.
  3. Take one task from the task queue and push it onto the stack; run it to completion.
  4. (The browser may render here.)
  5. Go back to step 2 and repeat.
flowchart TD A{Call stack empty?} -->|No| A A -->|Yes| B{Microtasks waiting?} B -->|Yes| C[Run ALL microtasks] C --> B B -->|No| D{Task waiting?} D -->|Yes| E[Run ONE task] E --> A D -->|No| F[Idle: wait for work] F --> D

βœ… The one sentence to remember

Synchronous code first, then all microtasks, then one macrotask β€” and repeat. Almost every "why did this log in that order?" puzzle is solved by applying that sentence.

Tracing a Full Example

Let's apply the algorithm to a program that mixes all the pieces. Read it, predict the output, then check yourself.

console.log('1: script start');

setTimeout(function timeout1() {
  console.log('5: setTimeout 1');
  Promise.resolve().then(() => console.log('6: promise inside timeout'));
}, 10);

setTimeout(function timeout2() {
  console.log('7: setTimeout 2');
}, 0);

Promise.resolve().then(function promise1() {
  console.log('3: promise 1');
  Promise.resolve().then(() => console.log('4: promise 2 (nested)'));
});

console.log('2: script end');

Actual output

1: script start
2: script end
3: promise 1
4: promise 2 (nested)
7: setTimeout 2
5: setTimeout 1
6: promise inside timeout

Why that order

  • 1, 2 β€” synchronous code runs first, top to bottom.
  • 3, 4 β€” the stack is empty, so microtasks drain: promise 1 runs and schedules a nested microtask, which also runs before we touch any timer.
  • 7 β€” now one macrotask: setTimeout 2 (0 ms) fires before setTimeout 1 (10 ms).
  • 5, 6 β€” the next macrotask, setTimeout 1, logs and schedules a microtask; that microtask drains immediately after this task, before any further macrotask.

Trace it with the one-sentence rule and it's mechanical, not mysterious.

Keeping the Thread Free

Because everything shares one stack, a long synchronous task starves the queues β€” no clicks, no rendering, no timers fire. Here's how to stay responsive.

Chunk heavy work

Split a big loop into slices and yield to the event loop between them so the browser can paint and handle input:

function processInChunks(items, work, chunkSize = 100, start = 0) {
  const end = Math.min(start + chunkSize, items.length);
  for (let i = start; i < end; i++) {
    work(items[i]);
  }
  if (end < items.length) {
    // Yield to the event loop, then continue the next chunk
    setTimeout(() => processInChunks(items, work, chunkSize, end), 0);
  }
}

Offload truly heavy computation to a Web Worker

For CPU-bound work, a Web Worker runs on a genuinely separate thread and communicates by messages, so the main thread stays free:

// main.js
const worker = new Worker('worker.js');
worker.addEventListener('message', (e) => {
  console.log('Worker result:', e.data);
});
worker.postMessage(bigDataset);

// worker.js
self.addEventListener('message', (e) => {
  const result = doHeavyComputation(e.data);
  self.postMessage(result);
});

⚠️ Watch out for microtask starvation

If a microtask keeps scheduling new microtasks, the queue never empties and macrotasks (including rendering) never get a turn. Use microtasks for quick reactions, not unbounded loops.

Hands-on Exercise

πŸ‹οΈ Predict, Then Verify

Objective: Cement the event-loop rules by predicting output before running the code.

Instructions:

  1. On paper (or in a comment), predict the exact console order of this snippet:
    console.log('A');
    
    setTimeout(() => console.log('B'), 0);
    
    Promise.resolve()
      .then(() => console.log('C'))
      .then(() => console.log('D'));
    
    queueMicrotask(() => console.log('E'));
    
    console.log('F');
  2. Paste it into your browser's DevTools console (or a .js file) and run it.
  3. Compare your prediction to the real output and explain any surprises using the one-sentence rule.
πŸ’‘ Hint

Synchronous logs (A, F) go first. Then all microtasks drain β€” but a chained .then only schedules its next callback after the previous one resolves, so C and E queue before D does. The lone setTimeout macrotask (B) comes dead last.

βœ… Solution

Output: A, F, C, E, D, B.

  • A, F β€” synchronous.
  • C β€” first .then microtask; E β€” the queueMicrotask callback (queued while synchronous, so it's in line right after C).
  • D β€” the second .then, which was only scheduled once C finished, so it lands after E.
  • B β€” the macrotask, after every microtask has drained.

🎯 Quick Quiz

Question 1: When does the event loop move a callback from a queue onto the call stack?

Question 2: A Promise .then callback and a setTimeout(fn, 0) callback are both ready. Which runs first?

Question 3: Why does a long synchronous while loop freeze the page?

Summary & Quiz

πŸŽ‰ Key Takeaways

  • The runtime has four parts: the call stack, Web APIs, the task queue, and the microtask queue.
  • Web APIs do slow work off the stack and place finished callbacks into a queue β€” never straight onto the stack.
  • The event loop runs: synchronous code β†’ all microtasks β†’ one macrotask β†’ repeat.
  • Microtasks (Promises) always beat macrotasks (timers, events) when both are ready.
  • Never block the single thread β€” chunk heavy loops or offload to a Web Worker.

πŸ“š Further Reading

πŸš€ What's Next?

Now that you know when callbacks run, we'll look at the oldest way to schedule them: callback functions β€” their patterns, their pitfalls (hello, callback hell), and why Promises came along.

πŸŽ‰ You cracked the loop!

Async ordering is now a set of rules you can trace, not a mystery. On to callbacks.