Skip to main content
JavaScript · lesson 16 of 24
JavaScript intermediate Lesson 16 of 24

Asynchronous 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/await is Promise code that reads top to bottom; rejections become exceptions.
  • fetch resolves on HTTP errors — check response.ok.
  • Start independent work together and await Promise.all; use allSettled when partial failure is acceptable.
  • race doesn’t cancel the losers; forEach doesn’t await async callbacks.

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.

Frequently Asked Questions

When should I use Promise.all vs Promise.allSettled?
Use Promise.all when all operations must succeed — it rejects immediately if any promise rejects. Use Promise.allSettled when you want results from all operations regardless of individual failures, such as running multiple independent API calls and reporting which ones failed.
Can I use await outside an async function?
Yes — top-level await is supported in ES modules (files with type: 'module' in Node.js, or .mjs files). In CommonJS or non-module contexts you still need an async wrapper function.
What happens if I forget to await a Promise?
The function continues synchronously without waiting for the result. The Promise runs in the background and any rejection becomes an unhandled Promise rejection, which can crash Node.js processes or produce silent bugs in the browser.