Skip to content

Callbacks, Promises, and Async/Await

Three different pieces of syntax, one single problem underneath all of them: how do you write code that has to wait — for a timer, a file, a server — without freezing everything else on the page while it waits? Callbacks, Promises, and async/await are three generations of the same answer, each one fixing a real problem with the generation before it. This tutorial walks through all three side by side, then puts a live, interactive visualizer right on this page so you can watch the mechanism underneath all of them — the event loop — actually run, instead of just reading about it.

Prerequisites: Your First JavaScript Program and Lecture 15: Asynchronous JavaScript — this tutorial assumes you're comfortable with functions and arrow functions, and it builds directly on Lecture 15's introduction to the call stack, the microtask queue, and the callback (task) queue, rather than re-explaining them from scratch.

In This Tutorial

  • Why callbacks were JavaScript's original async tool, and the real problem with them — callback hell
  • How Promises fix that problem: three states, .then/.catch/.finally, and chaining
  • How async/await is the same Promise machinery, written to read like synchronous code
  • A live, interactive event loop visualizer, embedded right on this page, that steps through a real mix of all three — synchronous code, a setTimeout, a Promise, and an async function — one engine action at a time
  • Why the console output order (1, 4, 6, 3, 5, 2) is not the order the code is written in, and exactly why not

Part 1: Callbacks — the Original Async Tool

A callback is just a function you hand to another function, to be called later, once some work finishes:

function loadUser(id, onDone) {
  setTimeout(() => {
    // pretend this took a real network request
    onDone({ id, name: "Ayesha" });
  }, 500);
}

loadUser(7, (user) => {
  console.log("Loaded:", user.name);
});
console.log("Request sent — not waiting around for it.");

loadUser returns immediately; onDone only runs once the (simulated) work is actually done, 500ms later. This is the entire idea behind every async pattern in this tutorial — the difference between callbacks, Promises, and async/await is never whether the program waits without blocking, only how readable the waiting code is.

Callback Hell

The trouble starts the moment one async step needs the result of the previous one. Each new step has to be nested inside the previous callback, because that's the only place its result is available:

loadUser(7, (user) => {
  loadOrders(user.id, (orders) => {
    loadOrderDetails(orders[0].id, (details) => {
      console.log(details);
      // ...and every additional step nests one level deeper
    }, handleError);
  }, handleError);
}, handleError);

This is callback hell (or the "pyramid of doom") — the indentation keeps marching to the right, error handling has to be repeated at every level, and reading the sequence of steps means reading the code inside-out. Promises exist specifically to fix this.

Not every callback is 'callback hell'

loadUser(7, onDone) by itself is a perfectly ordinary, fine callback — plenty of built-in APIs still work exactly this way (addEventListener, setTimeout). The hell only shows up once callbacks start nesting inside callbacks because each step depends on the last one's result — a structural problem, not a callback problem.

Part 2: Promises — a Flat Chain Instead of a Pyramid

A Promise is an object representing a value that doesn't exist yet, but will — either successfully (fulfilled) or with an error (rejected). It starts pending and can only change state once, after which it's permanently settled.

function loadUser(id) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (id <= 0) {
        reject(new Error("Invalid user id"));
        return;
      }
      resolve({ id, name: "Ayesha" });
    }, 500);
  });
}

loadUser(7)
  .then((user) => console.log("Loaded:", user.name))
  .catch((error) => console.error("Failed:", error.message))
  .finally(() => console.log("Request finished, either way"));
  • .then(onFulfilled) runs when the promise succeeds.
  • .catch(onRejected) runs when it fails.
  • .finally(fn) runs regardless — ideal for cleanup, like hiding a loading spinner.

Chaining Fixes the Pyramid

Every .then() returns a new Promise, so the nested pyramid from Part 1 flattens into a single, readable sequence — with one .catch() at the end catching a failure from any step above it:

loadUser(7)
  .then((user) => loadOrders(user.id))
  .then((orders) => loadOrderDetails(orders[0].id))
  .then((details) => console.log(details))
  .catch((error) => console.error("Something failed:", error.message));

Promise.all — Running Several at Once

Promise.all takes an array of promises and resolves once every one of them has — with an array of their results, in the same order — or rejects immediately if any single one rejects.

const results = await Promise.all([
  loadUser(1),
  loadUser(2),
  loadUser(3),
]);
console.log(results); // all three, together, in ~500ms total — not 1500ms

Reach for Promise.all whenever you need several independent pieces of data before continuing — loading a profile, its posts, and its settings all at once, instead of one after another.

Part 3: async/await — Promises That Read Like Sync Code

async/await doesn't replace Promises — it's syntax built directly on top of them, letting Promise-based code read top-to-bottom like ordinary synchronous code, while staying just as non-blocking underneath.

async function loadOrderSummary(userId) {
  const user = await loadUser(userId);
  const orders = await loadOrders(user.id);
  const details = await loadOrderDetails(orders[0].id);
  return details;
}

Two rules, and that's the whole feature:

  • async in front of a function makes it always return a Promise, and unlocks await inside it.
  • await pauses that function only (never the whole program) until the Promise it's waiting on settles — then "unwraps" the resolved value, or throws if the Promise rejected.

Error Handling with try...catch

Because a rejected await throws, you catch it exactly like any other exception — no .catch() chain required:

async function loadOrderSummary(userId) {
  try {
    const user = await loadUser(userId);
    const orders = await loadOrders(user.id);
    return await loadOrderDetails(orders[0].id);
  } catch (error) {
    console.error("Could not load order summary:", error.message);
    return null;
  }
}

Three tools, one engine

Callbacks, .then() chains, and async/await are not three different ways of being asynchronous — they're three different pieces of syntax sitting on top of the exact same underlying machine: the event loop. Part 4 makes that machine visible.

Part 4: The Event Loop, Visualized

JavaScript runs on a single thread — it can only execute one thing at a time, on one call stack. Every setTimeout, every Promise callback, every await continuation has to wait its turn to get back onto that stack, and the event loop is the mechanism that decides when and in what order that happens. The rule, in one sentence:

Run all synchronous code first. Once the call stack is empty, drain the entire microtask queue (Promises, async/await) before running even one task from the callback queue (setTimeout, setInterval, DOM events).

That single rule explains output that looks baffling at first glance. Here's the exact code the visualizer below runs, step by step:

console.log("1: start");

setTimeout(() => {
  console.log("2: timeout callback");
}, 0);

Promise.resolve().then(() => {
  console.log("3: promise callback");
});

async function asyncFn() {
  console.log("4: async start");
  await Promise.resolve();
  console.log("5: async resumed");
}
asyncFn();

console.log("6: end");

Even with the timer's delay set to 0, the real console output is 1, 4, 6, 3, 5, 2 — never 1, 2, 3, 4, 5, 6. Step through it below, one engine action at a time, and watch exactly why.

script.js
Call Stack
Web APIs
Microtask Queue
Callback Queue
Console Output
Click Next (or Play) to start executing the script, one step at a time.

Nothing here is specific to this one example

Swap in any mix of setTimeout, .then(), and await and the same two rules still decide the order: synchronous code runs first, then the entire microtask queue drains, then one task from the callback queue runs — and the loop repeats. That's the complete mental model, for any program you'll ever read or write.

Try It Yourself

  1. In the code above, change the timer's delay from 0 to a very large number like 5000 in your head — does the order of the six log lines change at all? (It doesn't — the callback queue is only ever checked after the microtask queue is empty, no matter how short the delay is.)
  2. Add a second Promise.resolve().then(() => console.log("7: second promise")) right after the first one. Predict where "7" lands in the final order before checking — it should land immediately after "3", since the microtask queue is first-in-first-out and both are already sitting in it before the stack ever empties.
  3. In a real file, write a loadUser/loadOrders/loadOrderDetails callback chain like Part 1's, then rewrite it twice — once with Promise chaining, once with async/await and try...catch. All three should produce identical output; only the readability changes.
  4. Using Promise.all, write a function that loads three independent pieces of data at once and logs how long the whole thing took with console.time/console.timeEnd — compare that against loading the same three things with three separate await calls in sequence.

Key Takeaways

  • A callback is a function handed to another function to run later; nesting callbacks whose results depend on each other produces callback hell — the problem Promises exist to solve.
  • A Promise is pending, then settles exactly once, into fulfilled or rejected. .then()/.catch()/.finally() react to that settlement, and chaining .then() calls flattens what used to be a nested pyramid into a single, linear sequence.
  • async/await is the same Promise machinery, written to read top-to-bottom like synchronous code — await only pauses the function it's inside, never the whole program, and errors are handled with ordinary try...catch.
  • The event loop's whole job is one rule: finish all synchronous code, then drain the entire microtask queue (Promises, await), then run one task from the callback queue (setTimeout, DOM events) — then repeat.
  • This is exactly why a Promise.resolve().then(...) always runs before a setTimeout(..., 0), no matter how the code is ordered on the page — microtasks are a strictly higher-priority queue, not just a faster timer.
// share