Marcell CD

JS event loop

Ever wondered how JavaScript can handle clicking buttons, loading data, and running animations all at the same time—despite being single-threaded? The secret sauce is the event loop, and it’s simpler than you think.

I want to explore the JS event loop with a clear and high-level approach. If you have struggled to understand how it worked before, this blog post could at least provide you with a brief overview of the JS event loop.

JavaScript is Single-Threaded by Nature

JavaScript runs on a single thread—this means it can only do one thing at a time. Unlike languages such as C++ that can have multiple threads running in parallel, JavaScript must handle tasks one after another. However, JavaScript cleverly provides the illusion of multitasking, thanks to asynchronous execution and the event loop.

The Big Picture

Imagine that you are about to cook a meal, but you can only do one thing at a time (single-threaded). You chop vegetables, then boil water, then cook the meat. If you had to wait for each step to finish before starting the next, it would take forever. Instead, you can start boiling water while chopping vegetables, and when the water is ready, you can add the pasta.

The Call Stack

The call stack is JavaScript’s to-do list. Every time a function is called, it’s placed (“pushed”) onto the stack. Functions run top-down: the most recent (top) task must finish before the next can continue. Synchronous code (like simple console.log statements) is processed here, one-by-one, until the stack is empty.

function first() {
  console.log("First")
  second()
}

function second() {
  console.log("Second")
}

first()
// Output:
// First
// Second

// Stack progression: first() → second() → empty

Here’s what happens step by step:

  1. first() is pushed onto the stack
  2. console.log('First') executes and is removed
  3. second() is pushed onto the stack
  4. console.log('Second') executes and is removed
  5. second() completes and is removed
  6. first() completes and is removed

Offloading Heavy Lifting: Web APIs & the Event Queue

What happens when JavaScript needs to do something slow, like a database query or an API call? This is where things get interesting. JavaScript delegates those blocking tasks to background helpers (Web APIs in browsers, or libuv in Node.js), freeing up the main thread to keep working on other code.

console.log("Start")

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

console.log("End")

// Output:
// Start
// End
// Timeout callback

Even with a 0ms delay, the setTimeout callback runs after the synchronous code. This demonstrates how async operations are handled differently.

Once those background tasks finish, their callbacks are sent to the task queue (also called the event queue). The task queue works on a “first in, first out” (FIFO) principle, First tasks to finish are the first to be processed.


## The Event Loop: The Traffic Controller

The event loop is constantly watching:

- Is the call stack empty?
- Are there any tasks waiting in the task queue?

If the stack is clear, it grabs the next task/callback from the queue and adds it to the call stack where it's executed just like any other function.

```javascript
console.log("1")

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

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

console.log("4")

// Output:
// 1
// 4
// 3
// 2

Wait, why does ‘3’ come before ‘2’? This brings us to an important detail…

Microtasks vs Macrotasks

Not all async operations are equal. JavaScript has two types of queues:

Microtask Queue (higher priority):

Macrotask Queue (lower priority):

The event loop always empties the microtask queue before moving to the macrotask queue.

console.log("Start")

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

Promise.resolve().then(() => console.log("Microtask 1"))
Promise.resolve().then(() => console.log("Microtask 2"))

console.log("End")

// Output:
// Start
// End
// Microtask 1
// Microtask 2
// Macrotask 1
// Macrotask 2

Real-World Example: Fetching Data

Let’s see how this works with a practical example:

function fetchUserData() {
  console.log("Starting fetch...")

  fetch("/api/user")
    .then((response) => response.json())
    .then((user) => {
      console.log("User data received:", user.name)
      updateUI(user)
    })
    .catch((error) => {
      console.error("Failed to fetch user:", error)
    })

  console.log("Fetch initiated, continuing with other work...")
}

function updateUI(user) {
  // This runs after the Promise resolves
  document.getElementById("username").textContent = user.name
}

fetchUserData()
console.log("This runs immediately")

// Output:
// Starting fetch...
// Fetch initiated, continuing with other work...
// This runs immediately
// User data received: John Doe (when the API responds)

Here the asynchronous nature of JavaScript allows the UI to remain responsive while waiting for the API response. The fetchUserData function initiates a fetch request, but instead of blocking the main thread, it allows other code to run while waiting for the response.

Common Pitfalls and How to Avoid Them

1. Blocking the Event Loop

// ❌ Bad: This blocks the event loop
function heavyComputation() {
  let result = 0
  for (let i = 0; i < 10000000000; i++) {
    result += i
  }
  return result
}

// ✅ Better: Break it into chunks
function chunkedComputation() {
  let result = 0
  const chunkSize = 100_00_00
  function computeChunk(start) {
    const end = start + chunkSize
    for (let i = start; i < end && i < 100_00_00_00_00; i++) {
      result += i
    }
    if (end < 100_00_00_00_00) {
      setTimeout(() => computeChunk(end), 0) // Yield control back to the event loop
    } else {
      console.log("Final result:", result)
    }
  }
  computeChunk(0)
}

2. Understanding Async/Await

async function example() {
  console.log("1")

  const result = await fetch("/api/data")
  console.log("2") // This waits for the fetch to complete

  console.log("3")
}

// This is equivalent to:
function exampleWithPromises() {
  console.log("1")
  return fetch("/api/data").then((result) => {
    console.log("2")
    console.log("3")
    return result
  })
}

Why Does It Matter?

Understanding the event loop helps you:

  1. Debug timing issues: Know why certain code executes in unexpected order
  2. Write more efficient asynchronous code: Avoid blocking the main thread
  3. Prevent UI freezing: Keep your applications responsive
  4. Recognize performance bottlenecks: Identify code that might block the event loop
  5. Master async/await and Promises: Understand why they behave the way they do

Wrapping Up

The event loop is the secret sauce that makes single-threaded JavaScript handle loads of concurrent tasks without freezing. By offloading work and queueing results, JavaScript remains efficient, responsive, and a powerful tool for modern web development.

Understanding it helps you:

The key takeaway? JavaScript may be single-threaded, but with the event loop, it’s like having a very efficient assistant that can juggle multiple tasks while keeping everything running smoothly.

This post covers the basics of the event loop. If you want to dive deeper, I recommend checking out the resources below for a more thorough understanding of the event loop.

Resources