JavaScript · lesson 16 of 24
Beginner
11- 1 Introduction to JavaScript
- 2 Setting Up JavaScript Development Environment
- 3 Variables in JavaScript: var, let, and const
- 4 JavaScript Data Types
- 5 JavaScript Operators
- 6 Control Flow in JavaScript
- 7 Functions in JavaScript
- 8 Working with Strings in JavaScript
- 9 Arrays in JavaScript
- 10 Objects in JavaScript
- 18 DOM Manipulation in JavaScript
Intermediate
10- 11 Error Handling in JavaScript
- 12 Object-Oriented Programming in JavaScript
- 13 Prototypes and Prototype Chain in JavaScript
- 14 Closures in JavaScript
- 15 JavaScript Event Loop Explained
- 16 Asynchronous JavaScript: Callbacks, Promises, and Async/Await
- 17 JavaScript Modules: ESM and CommonJS
- 19 Browser Web APIs in JavaScript
- 20 Testing JavaScript with Jest and Vitest
- 24 JavaScript Interview Preparation: Top 35 Questions
Advanced
3Asynchronous JavaScript: Callbacks, Promises, and Async/Await
Master async JavaScript by understanding callbacks, Promises, and async/await — including error handling, parallel execution, and the full Promise API.
By SkillByExample · Updated
JavaScript runs your code on one thread, so anything slow — a network call, a disk read, a timer — has to finish later and hand its result back. This lesson walks the three ways to write that: callbacks, Promises, and async/await. Every example runs with node; the “slow” operations are setTimeouts so you can see real timings.
Callbacks
The oldest pattern: pass a function to be called when the work finishes. Node’s convention is error-first — callback(err, result).
function fetchUser(id, callback) {
setTimeout(() => {
if (id <= 0) callback(new Error("Invalid ID"), null);
else callback(null, { id, name: "Alice" });
}, 100);
}
console.log("1. request sent");
fetchUser(1, (err, user) => {
if (err) return console.log("Failed:", err.message);
console.log("3. got user:", user.name);
});
fetchUser(-1, (err, user) => {
if (err) return console.log("3. failed:", err.message);
console.log("got user:", user.name);
});
console.log("2. fetchUser returned already");
$ node callbacks.js
1. request sent
2. fetchUser returned already
3. got user: Alice
3. failed: Invalid ID
fetchUser returns immediately — line 2 prints before either result. The callbacks run 100 ms later, when the timers fire. Nothing stops you from forgetting the if (err) check; the convention relies entirely on discipline.
Chaining dependent steps nests each one inside the last:
const getUser = (id, cb) => setTimeout(() => cb(null, { id, name: "Alice" }), 50);
const getOrders = (userId, cb) => setTimeout(() => cb(null, [{ id: 7, userId }]), 50);
const getInvoice = (orderId, cb) =>
setTimeout(() => (orderId === 7 ? cb(new Error("invoice 7 missing")) : cb(null, {})), 50);
getUser(1, (err, user) => {
if (err) return console.log("error:", err.message);
console.log("user", user.name);
getOrders(user.id, (err, orders) => {
if (err) return console.log("error:", err.message);
console.log("orders", orders.length);
getInvoice(orders[0].id, (err, invoice) => {
if (err) return console.log("error:", err.message);
console.log("invoice", invoice);
});
});
});
$ node hell.js
user Alice
orders 1
error: invoice 7 missing
Three steps, three levels of indentation and three copies of the same error check — “callback hell”. The third step failed and the error was handled, but only because every level remembered to check err.
Promises
A Promise is an object standing in for a value that isn’t ready yet. It is pending, then settles once — fulfilled with a value or rejected with an error — and never changes again.
function fetchUser(id) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (id <= 0) reject(new Error("Invalid ID"));
else resolve({ id, name: "Alice" });
}, 100);
});
}
const p = fetchUser(1);
console.log(p);
p.then((user) => console.log("got user:", user.name))
.finally(() => console.log("request 1 complete"));
fetchUser(-1)
.then((user) => console.log("got user:", user.name))
.catch((err) => console.log("failed:", err.message))
.finally(() => console.log("request 2 complete"));
setTimeout(() => console.log(p), 200);
$ node promise.js
Promise { <pending> }
got user: Alice
request 1 complete
failed: Invalid ID
request 2 complete
Promise { { id: 1, name: 'Alice' } }
Logging the promise straight away shows Promise { <pending> }; logged again after it settles, Node shows the value inside. For the failing request, .then is skipped and control jumps to .catch; .finally runs either way.
Chaining
.then() returns a new promise. Return a value and the next .then gets it; return a promise and the chain waits for it; throw and it skips to the nearest .catch.
const delay = (ms, value) => new Promise((r) => setTimeout(() => r(value), ms));
const getUser = (id) => delay(50, { id, name: "Alice" });
const getOrders = (userId) => delay(50, [{ id: 7, userId }]);
const getInvoice = (orderId) =>
orderId === 7 ? Promise.reject(new Error("invoice 7 missing")) : delay(50, {});
getUser(1)
.then((user) => { console.log("user", user.name); return getOrders(user.id); })
.then((orders) => { console.log("orders", orders.length); return getInvoice(orders[0].id); })
.then((invoice) => console.log("invoice", invoice))
.catch((err) => console.log("error:", err.message));
Promise.resolve(2)
.then((n) => n * 10)
.then((n) => { throw new Error(`too big: ${n}`); })
.then(() => console.log("never printed"))
.catch((err) => console.log("caught", err.message));
$ node chain.js
caught too big: 20
user Alice
orders 1
error: invoice 7 missing
The same three steps are now flat, with a single .catch at the end handling a failure from any of them. Notice caught too big: 20 printed first: the second chain has no timers, so its callbacks run as microtasks as soon as the synchronous code finishes, while the first chain is still waiting on setTimeout.
async / await
An async function always returns a promise, and await pauses that function (not the whole program) until a promise settles. Rejections become exceptions you catch with ordinary try/catch.
const delay = (ms, value) => new Promise((r) => setTimeout(() => r(value), ms));
const getUser = (id) => delay(50, { id, name: "Alice" });
const getOrders = (userId) => delay(50, [{ id: 7, userId }]);
const getInvoice = (orderId) =>
orderId === 7 ? Promise.reject(new Error("invoice 7 missing")) : delay(50, {});
async function loadPage(userId) {
try {
const user = await getUser(userId);
const orders = await getOrders(user.id);
const invoice = await getInvoice(orders[0].id);
return { user, orders, invoice };
} catch (err) {
return `could not load page: ${err.message}`;
}
}
const result = loadPage(1);
console.log(result instanceof Promise);
result.then(console.log);
console.log("loadPage is running in the background");
$ node await.js
true
loadPage is running in the background
could not load page: invoice 7 missing
loadPage returns a promise immediately, and the line after the call runs before any await resumes. Inside the function the code reads top to bottom like synchronous code, and the rejected getInvoice surfaced as an exception in the catch block.
fetch Doesn’t Reject on HTTP Errors
fetch (built into Node 18+) only rejects when the request can’t be made at all. A 404 or 500 is a successful fetch of an error page — you must check response.ok. This example starts its own local server so it runs offline.
import http from "node:http";
const server = http.createServer((req, res) => {
if (req.url === "/users/1") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ id: 1, name: "Alice" }));
} else {
res.writeHead(404).end("not found");
}
}).listen(0);
const base = `http://localhost:${server.address().port}`;
async function getJson(path) {
const response = await fetch(base + path);
if (!response.ok) throw new Error(`HTTP ${response.status} ${response.statusText}`);
return response.json();
}
for (const path of ["/users/1", "/users/2"]) {
try { console.log(path, await getJson(path)); }
catch (err) { console.log(path, "failed:", err.message); }
}
server.close();
try { await fetch("http://127.0.0.1:59999/"); }
catch (err) { console.log("nothing listening:", err.message, "-", err.cause.code); }
$ node fetch.mjs
/users/1 { id: 1, name: 'Alice' }
/users/2 failed: HTTP 404 Not Found
nothing listening: fetch failed - ECONNREFUSED
Without the response.ok check, /users/2 would have gone on to response.json() and failed on "not found" with a confusing JSON parse error. Only the request to port 59999, where nothing is listening, actually rejected — with a vague fetch failed; the real reason, ECONNREFUSED, is on err.cause. (The file is .mjs so it can use import and top-level await.)
Sequential vs. Parallel
Each await waits for the previous one to finish. If the operations don’t depend on each other, start them all and await them together with Promise.all.
const delay = (ms, value) => new Promise((r) => setTimeout(() => r(value), ms));
const fetchUser = () => delay(100, "user");
const fetchPosts = () => delay(100, "posts");
const fetchFriends = () => delay(100, "friends");
async function timed(label, fn) {
const start = performance.now();
const result = await fn();
const ms = Math.round((performance.now() - start) / 10) * 10;
console.log(label.padEnd(11), `~${ms} ms`, result);
}
await timed("sequential", async () => [
await fetchUser(), await fetchPosts(), await fetchFriends(),
]);
await timed("parallel", () =>
Promise.all([fetchUser(), fetchPosts(), fetchFriends()]),
);
$ node parallel.mjs
sequential ~300 ms [ 'user', 'posts', 'friends' ]
parallel ~100 ms [ 'user', 'posts', 'friends' ]
Same results, a third of the time. Promise.all takes as long as the slowest input, not the sum. The calls in the parallel version all start before any is awaited — that’s the whole trick.
When One Fails: all vs. allSettled
Promise.all rejects as soon as any input rejects and throws away the others’ results. Promise.allSettled waits for every input and reports each outcome.
const delay = (ms, value) => new Promise((r) => setTimeout(() => r(value), ms));
const fail = (ms, msg) => new Promise((_, rej) => setTimeout(() => rej(new Error(msg)), ms));
const widgets = () => [
delay(50, "profile"),
fail(20, "notifications service down"),
delay(80, "recommendations"),
];
try {
console.log(await Promise.all(widgets()));
} catch (err) {
console.log("all rejected:", err.message);
}
const results = await Promise.allSettled(widgets());
for (const r of results) {
console.log(r.status.padEnd(9), r.status === "fulfilled" ? r.value : r.reason.message);
}
$ node settled.mjs
all rejected: notifications service down
fulfilled profile
rejected notifications service down
fulfilled recommendations
With Promise.all, one broken widget blanks the whole dashboard — the two successful results are lost. allSettled never rejects; you get an array of { status, value } / { status, reason } objects and decide per item what to render.
race and any
Promise.race settles with whichever input settles first — success or failure — which makes it a timeout. Promise.any takes the first success, ignoring failures unless every input fails.
const delay = (ms, value) => new Promise((r) => setTimeout(() => r(value), ms));
const fail = (ms, msg) => new Promise((_, rej) => setTimeout(() => rej(new Error(msg)), ms));
function withTimeout(promise, ms) {
let timer;
const timeout = new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error(`timed out after ${ms} ms`)), ms);
});
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
}
console.log(await withTimeout(delay(50, "fast response"), 100));
try { await withTimeout(delay(500, "slow response"), 100); }
catch (err) { console.log(err.message); }
console.log(await Promise.any([fail(10, "server A down"), delay(60, "server B"), delay(40, "server C")]));
try { await Promise.any([fail(10, "A down"), fail(20, "B down")]); }
catch (err) { console.log(err.name, err.errors.map((e) => e.message)); }
$ node race.mjs
fast response
timed out after 100 ms
server C
AggregateError [ 'A down', 'B down' ]
race doesn’t cancel the loser: the slow delay(500) promise still runs to completion — you just stop waiting for it. The clearTimeout in .finally matters for the same reason; without it a pending timer keeps the process alive after a fast success. any skipped server A’s rejection and returned server C, the fastest success; when everything fails it rejects with an AggregateError holding every reason.
Common Mistake: async Inside forEach
forEach ignores the promise an async callback returns, so nothing waits for the work.
const delay = (ms) => new Promise((r) => setTimeout(r, ms));
const saved = [];
async function saveUser(id) { await delay(30); saved.push(id); }
[1, 2, 3].forEach(async (id) => { await saveUser(id); });
console.log("after forEach:", saved);
saved.length = 0;
for (const id of [1, 2, 3]) await saveUser(id);
console.log("after for...of:", saved);
saved.length = 0;
await Promise.all([1, 2, 3].map((id) => saveUser(id)));
console.log("after Promise.all(map):", saved);
$ node foreach.mjs
after forEach: []
after for...of: [ 1, 2, 3, 1, 2, 3 ]
after Promise.all(map): [ 1, 2, 3 ]
The first log ran before any save finished, so it printed an empty array. Worse, the three forgotten saves kept running: they finished while the for...of loop was awaiting its first save, and pushed their 1, 2, 3 into the array after it had been cleared — hence six entries. Un-awaited work doesn’t disappear; it lands later, somewhere you’ve stopped looking. for...of awaits each save in turn; Promise.all over map runs them concurrently and waits for all of them.
Key Takeaways
- Callbacks work, but nest and repeat error handling; Promises flatten the chain.
async/awaitis Promise code that reads top to bottom; rejections become exceptions.fetchresolves on HTTP errors — checkresponse.ok.- Start independent work together and
await Promise.all; useallSettledwhen partial failure is acceptable. racedoesn’t cancel the losers;forEachdoesn’t awaitasynccallbacks.
Practice
1. Convert the callback-style fetchUser(id, cb) into a promise-returning function using util.promisify, and await it.
import { promisify } from "node:util";
function fetchUser(id, callback) {
setTimeout(() => {
if (id <= 0) callback(new Error("Invalid ID"));
else callback(null, { id, name: "Alice" });
}, 50);
}
const fetchUserAsync = promisify(fetchUser);
console.log(await fetchUserAsync(1));
try { await fetchUserAsync(0); } catch (err) { console.log("rejected:", err.message); }
$ node p1.mjs
{ id: 1, name: 'Alice' }
rejected: Invalid ID
promisify works on any function that follows the error-first convention: a non-null first argument rejects, otherwise the second argument fulfills.
2. Write retry(fn, attempts) that awaits fn() and retries on rejection, rethrowing the last error.
async function retry(fn, attempts) {
for (let i = 1; i <= attempts; i++) {
try { return await fn(); }
catch (err) {
console.log(`attempt ${i} failed: ${err.message}`);
if (i === attempts) throw err;
}
}
}
let calls = 0;
const flaky = async () => { if (++calls < 3) throw new Error("503"); return "ok"; };
console.log(await retry(flaky, 5));
try { await retry(async () => { throw new Error("down"); }, 2); }
catch (err) { console.log("gave up:", err.message); }
$ node p2.mjs
attempt 1 failed: 503
attempt 2 failed: 503
ok
attempt 1 failed: down
attempt 2 failed: down
gave up: down
The return await matters: plain return fn() would hand back the promise without waiting, so its rejection would escape the try.
3. Process ten jobs of 50 ms each, at most three at a time, and print the total time.
const delay = (ms) => new Promise((r) => setTimeout(r, ms));
const jobs = Array.from({ length: 10 }, (_, i) => i);
async function runLimited(items, limit, worker) {
const queue = [...items];
const runners = Array.from({ length: limit }, async () => {
while (queue.length) await worker(queue.shift());
});
await Promise.all(runners);
}
const start = performance.now();
await runLimited(jobs, 3, () => delay(50));
console.log(`~${Math.round((performance.now() - start) / 50) * 50} ms`);
$ node p3.mjs
~200 ms
Ten jobs through three lanes takes four rounds (3 + 3 + 3 + 1), so about 200 ms — versus 500 ms sequentially or 50 ms with unlimited parallelism.
4. Predict the output order, then run it.
async function f() {
console.log("B");
await null;
console.log("D");
}
console.log("A");
f();
console.log("C");
$ node p4.js
A
B
C
D
Everything up to the first await runs synchronously, so B prints before C. Even await null suspends the function; the rest resumes as a microtask after the current synchronous code finishes.