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/awaitis 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 anasyncfunction — 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:
asyncin front of a function makes it always return a Promise, and unlocksawaitinside it.awaitpauses 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.
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¶
- In the code above, change the timer's delay from
0to a very large number like5000in 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.) - 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. - In a real file, write a
loadUser/loadOrders/loadOrderDetailscallback chain like Part 1's, then rewrite it twice — once with Promise chaining, once withasync/awaitandtry...catch. All three should produce identical output; only the readability changes. - Using
Promise.all, write a function that loads three independent pieces of data at once and logs how long the whole thing took withconsole.time/console.timeEnd— compare that against loading the same three things with three separateawaitcalls 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/awaitis the same Promise machinery, written to read top-to-bottom like synchronous code —awaitonly pauses the function it's inside, never the whole program, and errors are handled with ordinarytry...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 asetTimeout(..., 0), no matter how the code is ordered on the page — microtasks are a strictly higher-priority queue, not just a faster timer.