intermediate20 min

Asynchronous Programming: Callbacks, Promises, and Async/Await

Three syntaxes for the same underlying idea — and the one mistake (an unawaited or unhandled promise) that silently swallows errors in a real server.

What you'll learn

  • Convert a callback-style function into a Promise-returning one
  • Explain what async/await actually is underneath — sugar over Promises, not a separate mechanism
  • Identify an unhandled promise rejection and how to prevent it

Prerequisites

Explanation

Node's original async style was callbacks: a function takes a function as its last argument, and calls it once the async work finishes, conventionally as callback(error, result) — error first, by convention, so it can never be silently ignored by accident the way a thrown exception in async code once could be. This works, but nesting several callback-dependent steps produces the infamous "callback hell": deeply nested, hard-to-read, hard-to-error-handle pyramids of code.

Promises wrap that same "eventually finishes, successfully or with an error" idea in an object with a cleaner API: .then() for success, .catch() for failure, and — crucially — they chain flatly instead of nesting, since each .then() returns a new promise. async/await is not a third, separate mechanism — it's syntax sugar over Promises, letting asynchronous code read like synchronous code (no .then() chains) while still being genuinely non-blocking underneath. await somePromise pauses that async function (not the whole thread — everything else keeps running) until the promise settles, then resumes with its value, or throws if it rejected.

That "or throws if it rejected" is the single most important operational fact for a real server: an awaited promise that rejects becomes a genuine thrown exception, catchable with an ordinary try/catch. A promise that's created but never awaited, chained, or explicitly handled — and later rejects — becomes an unhandled promise rejection, which in a real Node process can crash the entire server (depending on configuration) or, worse, silently vanish, leaving a request hanging forever with no response and no error logged anywhere. In an Express route handler specifically, this is exactly why every async route needs its errors properly caught and forwarded — a topic the error-handling lesson covers directly.

Example

The same asynchronous operation in three styles: callback, Promise, and async/await -- functionally equivalent, syntactically progressive.

function getUserCallback(id, callback) {
  setTimeout(() => callback(null, { id, name: "Ada" }), 50);
}

function getUserPromise(id) {
  return new Promise((resolve) => {
    setTimeout(() => resolve({ id, name: "Ada" }), 50);
  });
}

async function getUserAsync(id) {
  const user = await getUserPromise(id);
  return user;
}

getUserCallback(1, (err, user) => console.log("callback style:", user));
getUserPromise(1).then((user) => console.log("promise style:", user));
getUserAsync(1).then((user) => console.log("async/await style:", user));

Try it yourself

Make getUserPromise reject instead of resolve, then observe what happens to each style's error handling (or lack of it).

Code editor. Press Escape then Tab to leave the editor if keyboard focus becomes trapped. Press Control+Shift+M inside the editor to toggle Tab-key focus trapping.

Loading editor…

Guided exercise

Guided exercise

Write promisify(callbackStyleFn) that converts a Node-style callback function (last argument is callback(error, result)) into a function returning a Promise. It should take the same arguments MINUS the callback, and return a new Promise that resolves with result or rejects with error.

Checks: resolves correctly for a successful callback · rejects correctly for a callback error

Code editor. Press Escape then Tab to leave the editor if keyboard focus becomes trapped. Press Control+Shift+M inside the editor to toggle Tab-key focus trapping.

Loading editor…

Stuck? Get a hint.

Independent exercise

Independent exercise

Write safeAsyncCall(asyncFn) that wraps an async function so it NEVER throws or rejects unhandled -- instead it returns a Promise that resolves to { ok: true, value } on success or { ok: false, error: message } on failure, modeling how a real route handler should defensively wrap async work.

Checks: wraps a successful call correctly · wraps a failing call correctly, without itself throwing

Code editor. Press Escape then Tab to leave the editor if keyboard focus becomes trapped. Press Control+Shift+M inside the editor to toggle Tab-key focus trapping.

Loading editor…

Stuck? Get a hint.

Common mistakes

  • Creating a Promise (calling an async function, for instance) without awaiting, chaining, or otherwise handling it, risking an unhandled rejection if it fails.
  • Nesting several callback-dependent async steps instead of converting to Promises or async/await, producing hard-to-read, hard-to-error-handle code.
  • Forgetting that `await` only pauses the current async function, not the entire thread or process — other work keeps running concurrently.

Knowledge check

Knowledge check

1. What is async/await, mechanically?
2. What happens to a Promise that rejects but is never awaited, chained with .catch(), or otherwise handled?
3. Why do callback-style Node APIs conventionally put the error as the FIRST argument (`callback(error, result)`)?

Takeaway

Callbacks, Promises, and async/await are three syntaxes for the same underlying asynchronous idea — and an unhandled promise rejection is a real operational hazard in a server, not a harmless edge case.

Summary

This lesson covered converting between callback and Promise styles, what async/await actually does underneath, and the real risk of unhandled promise rejections in a running Node process.

References

Your notes

Notes save automatically.

Finished this lesson?

Mark it complete to track your progress and schedule a future review.