Async Patterns (callbacks → promises → async/await)
Reviewed & published by Brayan K
Asynchronous code lets Node start slow work — files, networks, timers — and keep running instead of freezing, then handle the result later with callbacks, promises, or async/await.
Part of the free Node.js course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
By the end of this lesson you'll trace how a callback runs later, see why nested callbacks become "callback hell," rewrite the same logic with promises, and finally with clean async/await using try/catch.
What You'll Learn in This Lesson
- What a callback is and why Node runs it later, not now
- How nested callbacks become hard-to-read callback hell
- How a Promise represents a value that isn't ready yet
- Chaining with .then() and handling failures with .catch()
- Writing flat, readable async code with async/await
- Catching async errors with try/catch and running work in parallel
1️⃣ Callbacks: "Call Me Back When You're Done"
The oldest async pattern in Node is the callback: you pass a function into a slow operation, and Node runs that function once the work finishes. Because Node is non-blocking, the rest of your script keeps running in the meantime. Read the comments and predict the print order before you check.
// A CALLBACK is just a function you hand to another function so it can
// "call you back" once a slow task finishes. Node uses them everywhere.
// setTimeout simulates slow work (a file read, a network call, a timer).
// The arrow function is the callback — Node runs it AFTER 1000 ms.
console.log("1. Asking for the data...");
setTimeout(() => {
// This runs LATER, once the wait is over.
console.log("3. Got the data!");
}, 1000);
console.log("2. Meanwhile, the rest of the script keeps running.");
// Because Node is non-blocking, line 2's message prints BEFORE the
// callback. The order is 1, 2, 3 — not 1, 3, 2.Callbacks work well for one step. The trouble starts when each async step depends on the previous one — the code nests sideways into a staircase known as callback hell.
// The problem: when one async step depends on the next, callbacks nest
// deeper and deeper. This sideways "staircase" is called CALLBACK HELL.
function getUser(id, cb) {
setTimeout(() => cb(null, { id, name: "Sam" }), 200);
}
function getOrders(user, cb) {
setTimeout(() => cb(null, ["Book", "Pen"]), 200);
}
// Each step is nested inside the previous step's callback.
getUser(1, (err, user) => {
if (err) throw err;
console.log("User:", user.name);
getOrders(user, (err, orders) => {
if (err) throw err;
console.log("Orders:", orders.join(", "));
// ...imagine a third and fourth level here. It gets ugly fast.
});
});2️⃣ Promises: A Value That Isn't Ready Yet
A promise is an object that stands in for a result that will arrive later. It either resolves (success) or rejects (failure). You attach .then() for success and .catch() for failure — and chaining .then() keeps sequential steps flat instead of nested.
// A PROMISE is an object representing a value that ISN'T ready yet.
// It will eventually either RESOLVE (success) or REJECT (failure).
// Make a promise that resolves after 500 ms.
function getData() {
return new Promise((resolve, reject) => {
setTimeout(() => resolve("the data"), 500);
// reject(new Error("oops")) would signal failure instead.
});
}
// .then() runs on success, .catch() runs on failure.
// Chaining .then() keeps async steps flat instead of nested.
getData()
.then((value) => {
console.log("Resolved with:", value);
return "next step";
})
.then((step) => console.log("Then ran:", step))
.catch((err) => console.log("Failed:", err.message));
console.log("Promise created — code keeps running.");3️⃣ async/await: Async Code That Reads Top-to-Bottom
async/await is syntax built on promises. Mark a function async, then use await to pause until a promise resolves and hand you its value — no .then(), no nesting. Errors are caught with ordinary try/catch. Remember: await pauses only its own function, never the whole program.
// ASYNC/AWAIT is syntax sugar over promises. It lets you WRITE async
// code that READS like normal top-to-bottom code.
function getUser(id) {
// A function that returns a promise.
return new Promise((resolve) => {
setTimeout(() => resolve({ id, name: "Sam" }), 200);
});
}
// "async" marks a function that can use "await" inside it.
async function main() {
// "await" pauses HERE until the promise resolves, then gives you the value.
// No .then(), no nesting — it looks synchronous, but it isn't blocking.
const user = await getUser(1);
console.log("User name:", user.name);
// try/catch handles rejected promises just like normal errors.
try {
const n = await Promise.resolve(42);
console.log("Answer:", n);
} catch (err) {
console.log("Error:", err.message);
}
}
main();
console.log("main() started; this line runs first.");Notice that "main() started" prints first: calling an async function returns immediately, and the awaited work resumes later. That's the non-blocking event loop at work.
Your turn. Finish the async function below by replacing each marked spot. Follow the // TODO comments, then run it and compare with the expected output.
// 🎯 YOUR TURN — finish the async function, then run: node fetchUser.js
// A helper that returns a promise resolving to a user object after 300 ms.
function fetchUser(id) {
return new Promise((resolve) => {
setTimeout(() => resolve({ id, name: "Alex" }), 300);
});
}
// TODO 1: mark this function as "async" so you can use await inside it.
function showUser() {
// TODO 2: await fetchUser(1) and store the result in a const called "user".
const user = ___;
// TODO 3: log the name with a template literal.
// Template literals use backticks and ${...} to insert a value.
console.log(`Loaded user: ${user.name}`);
}
showUser();
// ✅ Expected output:
// Loaded user: AlexMini-Challenge: Load Two Things in Parallel
No blanks this time — just a brief and an outline. Use Promise.all to await two promises at once, then combine the results into one line. Run it with node parallel.js and check your output against the example.
// 🎯 MINI-CHALLENGE: load two things "in parallel", then combine them.
// Save as parallel.js and run: node parallel.js
//
// 1. Write two functions, getName() and getAge(), that each return a
// promise (use setTimeout to fake the delay).
// 2. In an async main(), use Promise.all([...]) to await BOTH at once.
// 3. Destructure the results and print a single combined line.
//
// Why Promise.all? Running them together is faster than awaiting one,
// then the other.
//
// ✅ Example output:
// Sam is 30 years old.
// your code hereCommon Errors (and the fix)
- "SyntaxError: await is only valid in async functions" — You used await in a normal function. Add the async keyword to the function that contains it.
- Logging a promise prints Promise { <pending> } — You forgot to await it (or chain .then()). The value isn't ready yet; await it to unwrap the result.
- "UnhandledPromiseRejection" — A promise rejected with no handler. Add a .catch(), or wrap the await in try/catch.
- Forgetting to return inside .then() — If a .then() doesn't return its value, the next .then() receives undefined. Always return the value you want to pass along.
- Awaiting in a loop when you meant parallel — await inside a for loop runs items one at a time. For independent work, collect promises and use Promise.all.
Pro Tips
- 💡 Prefer async/await: for new code it's the clearest of the three. Reach for raw promises mainly when combining many at once with Promise.all.
- 💡 One try/catch can cover several awaits: you don't need a separate try/catch per line — wrap a logical block.
- 💡 Async returns a promise: every async function gives back a promise, so callers can await it too.
📋 Quick Reference — Async Patterns
| Goal | Syntax |
|---|---|
| Run later (callback) | setTimeout(cb, ms) |
| Make a promise | new Promise((resolve, reject) => {}) |
| Handle success / failure | .then(v => ...).catch(e => ...) |
| Await a value | const v = await p |
| Catch async errors | try { await p } catch (e) {} |
| Run in parallel | await Promise.all([a, b]) |
🎉 Lesson Complete!
- ✅ A callback is a function Node runs later, once slow work finishes
- ✅ Deeply nested callbacks become unreadable "callback hell"
- ✅ A promise represents a value that isn't ready yet — .then() / .catch()
- ✅ async/await makes async code read top-to-bottom, with try/catch
- ✅ await pauses only its function — it never blocks the whole program
- ✅ Use Promise.all to run independent async work in parallel
Practice quiz
What is a callback in Node.js?
- A way to pause the whole program
- A special kind of variable
- A function passed to another function to run later when work finishes
- A built-in Node module
Answer: A function passed to another function to run later when work finishes. A callback is a function you hand to an async operation so it can call you back once the slow work completes.
In the callback example, what order do the three messages print?
- 1, 2, 3
- 1, 3, 2
- 3, 2, 1
- 2, 1, 3
Answer: 1, 2, 3. Because Node is non-blocking, the synchronous line 2 runs before the setTimeout callback (line 3).
What term describes deeply nested callbacks that grow sideways?
- Promise chaining
- Event looping
- Async sprawl
- Callback hell
Answer: Callback hell. When each async step nests inside the previous callback, the staircase shape is called callback hell.
What does a Promise represent?
- A synchronous return value
- A value that is not ready yet and will resolve or reject later
- A timer that always fires
- A network socket
Answer: A value that is not ready yet and will resolve or reject later. A Promise stands in for a future result that eventually resolves (success) or rejects (failure).
Which method handles a successful promise value?
- .then()
- .catch()
- .finally()
- .reject()
Answer: .then(). .then() runs on success; .catch() runs on failure.
What keyword lets you use await inside a function?
- defer
- yield
- async
- wait
Answer: async. Marking a function async lets you use await inside it; await is only valid in async functions.
Does await block the entire program while it waits?
- Yes, the whole process freezes
- No, it only pauses its own async function
- Only on Windows
- Only for network calls
Answer: No, it only pauses its own async function. await pauses just the async function it is in; the event loop keeps running everything else.
What does every async function return?
- undefined always
- A callback
- A Buffer
- A promise
Answer: A promise. An async function always returns a promise, so callers can await it too.
When should you use Promise.all?
- To run promises strictly one after another
- To run independent async operations in parallel and await them together
- To cancel a promise
- To convert a callback to a promise
Answer: To run independent async operations in parallel and await them together. Promise.all starts independent operations together, so total time is the slowest one, not the sum.
How do you catch a rejected promise when using await?
- Add a second argument to await
- Use .then() only
- Wrap the await in try/catch
- It cannot be caught
Answer: Wrap the await in try/catch. try/catch around an await handles a rejected promise just like a thrown error.
Continue this course
- Previous: Routing by Hand
- Next: Error Handling — Handle errors in sync, callback, and async code without crashing
- Quick reference: Node.js cheat sheet › Async
Frequently asked questions
What is the difference between a callback and a promise?
A callback is a function you pass into an async operation so it can run your code later, once the work finishes. A promise is an object that represents the eventual result of that work — you attach .then() and .catch() handlers to it instead of passing a function in. The big practical difference is composition: chaining promises with .then() keeps sequential async steps flat and readable, while chaining callbacks nests them deeper and deeper (the dreaded callback hell). Promises also have built-in error propagation, so one .catch() at the end can handle a failure anywhere in the chain.
Is async/await different from promises, or the same thing?
Under the hood they are the same thing — async/await is syntax built on top of promises. An async function always returns a promise, and await simply pauses inside that function until a promise settles, then unwraps its value. The benefit is purely readability: await lets you write asynchronous steps as if they were ordinary top-to-bottom code, with normal try/catch for errors, instead of chains of .then() and .catch(). Any code you can write with async/await you could also write with raw promises; it just usually reads worse.
Does await block the whole program while it waits?
No. await only pauses the async function it is inside; the rest of your program — other functions, timers, incoming requests — keeps running. That is the key insight people miss: await is non-blocking. Node hands control back to its event loop while the awaited promise is pending, so a single Node process can be awaiting thousands of things at once without freezing. The function resumes from the await line only after the promise resolves.
When should I use Promise.all instead of awaiting things one by one?
Use Promise.all when you have several independent async operations that do not depend on each other's results — for example fetching a user and their settings from two different sources. Awaiting them one after another runs them sequentially, so the total time is the sum of both. Promise.all starts them together and waits for all to finish, so the total time is just the slowest one. If a later step needs the result of an earlier step, you must await sequentially instead.