Complete JavaScript Course Module 12: Advanced: Asynchronous JavaScript

How Asynchronous JavaScript Works: Callbacks and the Event Loop

  • Beginner
  • Lesson 12.1
  • 6 min read
  • 52 of 76 in the course

In short

Asynchronous JavaScript lets your code start a slow task, keep working, and handle the result later. This lesson explains callbacks, the call stack and the event loop. What is the difference between synchronous and asynchronous code? Synchronous code runs one line at a time, in order.

Asynchronous JavaScript lets your code start a slow task, keep working, and handle the result later. This lesson explains callbacks, the call stack and the event loop.

What is the difference between synchronous and asynchronous code?

Synchronous code runs one line at a time, in order. Each line must finish before the next one starts. Asynchronous code starts a task now and finishes it later, so the rest of your code does not have to wait.

Think of a restaurant. A synchronous waiter takes your order, walks to the kitchen, stands there until your food is cooked, brings it to you, and only then takes the next order. Everyone else waits. An asynchronous waiter takes your order, gives it to the kitchen, and goes to serve other tables. When the kitchen rings the bell, the waiter brings your food. The kitchen is doing the slow work in the background.

JavaScript in the browser works like the second waiter. It has only one main thread, which means it can only do one thing at a time. Slow jobs like timers, network requests and waiting for clicks are handed to the browser, and JavaScript keeps going.

console.log("1. Take order from table 1");

setTimeout(() => {
  console.log("3. Food for table 1 is ready");
}, 2000);

console.log("2. Take order from table 2");

// Output:
// 1. Take order from table 1
// 2. Take order from table 2
// 3. Food for table 1 is ready   (after 2 seconds)

setTimeout hands the timer to the browser and returns at once. That is why line 2 prints before line 3, even though it comes later in the code.

How does the JavaScript event loop work?

To understand the order above, you need four pieces:

  • Call stack: the list of functions JavaScript is running right now. When a function is called, it goes on top of the stack. When it returns, it is removed. Like a stack of plates, the last one in is the first one out.
  • Web APIs: tools the browser gives you, like setTimeout, fetch and addEventListener. They do the waiting outside of JavaScript.
  • Task queue (also called the macrotask queue): when a timer finishes or a click happens, the callback waits in this line.
  • Microtask queue: a second, more urgent line. Promise callbacks (you will meet promises in the next lesson) wait here.

The event loop is a simple helper that keeps checking: "Is the call stack empty? If yes, run all waiting microtasks first, then take one task from the task queue."

   Your code                Browser (Web APIs)
 +-------------+          +---------------------+
 | Call stack  | -------> | setTimeout, fetch,  |
 |             |          | click listeners ... |
 +-------------+          +---------------------+
        ^                            |
        |                            v
        |              +---------------------------+
        |              | Microtask queue           |  <- promises (run first)
        |              +---------------------------+
        |              | Task queue                |  <- timers, events
        |              +---------------------------+
        |                            |
        +-------- Event loop --------+
     "Stack empty? Run all microtasks, then one task."

Here is a small test that shows microtasks run before tasks, even when the timer has a delay of 0:

console.log("A: start");

setTimeout(() => console.log("D: timeout (task)"), 0);

Promise.resolve().then(() => console.log("C: promise (microtask)"));

console.log("B: end");

// Output:
// A: start
// B: end
// C: promise (microtask)
// D: timeout (task)

First, all the normal code runs (A and B). The stack is now empty, so the event loop runs the microtask (C). Only then does it take the timer task (D). A delay of 0 means "as soon as possible", not "right now".

Note: Because there is only one main thread, a long loop in your code blocks everything: clicks, timers and even page drawing. Keep heavy work short, or split it into smaller pieces.

What is a callback function in JavaScript?

A callback is a function you pass to another function, so it can be called later. You have already used callbacks with addEventListener and setTimeout. Callbacks were the first way JavaScript handled async work.

function loadUser(id, callback) {
  console.log(`Loading user ${id}...`);
  setTimeout(() => {
    const user = { id: id, name: "Maya" };
    callback(user); // call the function we were given
  }, 1000);
}

loadUser(7, (user) => {
  console.log(`Hello, ${user.name}!`);
});

// Output:
// Loading user 7...
// Hello, Maya!   (after 1 second)

loadUser pretends to fetch a user from a server. It cannot return the user, because the data is not ready yet when the function ends. So instead, it calls the callback when the data arrives.

What is callback hell?

Callbacks work well for one step. But real apps often need several steps in order: load the user, then their orders, then the details of the first order. With callbacks, each step goes inside the one before it.

function getUser(id, cb) { setTimeout(() => cb({ id, name: "Maya" }), 500); }
function getOrders(user, cb) { setTimeout(() => cb([{ id: 101 }, { id: 102 }]), 500); }
function getOrderDetails(order, cb) { setTimeout(() => cb({ id: order.id, total: 49.99 }), 500); }

getUser(7, (user) => {
  getOrders(user, (orders) => {
    getOrderDetails(orders[0], (details) => {
      console.log(`${user.name}'s first order costs $${details.total}`);
    });
  });
});

// Output (after 1.5 seconds):
// Maya's first order costs $49.99

The code moves further and further to the right. This shape is called callback hell or the "pyramid of doom". It is hard to read, and error handling is painful, because you need to check for errors at every level. Promises and async/await, which you will learn next, fix this problem.

Common mistake: Trying to return a value from inside a setTimeout callback and expecting the outer function to give it back. The outer function has already finished. Use a callback, a promise or await instead.

Practice exercise

Without running it first, write down the order in which this code prints its letters. Then run it in the console to check your answer.

console.log("W");
setTimeout(() => console.log("X"), 0);
Promise.resolve().then(() => console.log("Y"));
console.log("Z");

Hint: normal code first, then microtasks (promises), then tasks (timers).

Key takeaways

  • JavaScript has one main thread, so slow jobs are handed to browser Web APIs.
  • The call stack runs your code; finished async jobs wait in queues.
  • The event loop runs all microtasks (promises) before the next task (timers, events).
  • A callback is a function passed in to be called later.
  • Deeply nested callbacks create "callback hell", which promises solve.

Frequently asked questions

Is JavaScript single-threaded?

Yes. Your JavaScript code runs on one main thread. The browser itself uses other threads for timers and network requests, which is why JavaScript can still do async work.

Why does setTimeout with 0 milliseconds not run immediately?

The callback must wait in the task queue until the call stack is empty and all microtasks are done. Zero only means "no extra delay".

Are callbacks still used today?

Yes, for events (addEventListener) and array methods like map. For one-time async results, modern code uses promises and async/await instead.