Command Palette

Search for a command to run...

Hectal
PHASE 10Intermediate ~20 min· topic 4 of 8

Topic 10.4

Callbacks and Callback Hell

In one line

A callback is a function you pass to another function so it can call you back when a job finishes; it's the oldest way to handle async results, with an error-first convention, but nesting them gets messy and you must trust the code that calls them.

Think of it like this

Leaving your phone number at a shop. The shop doesn't have your size, so you leave your number: "call me when it arrives". You go home and get on with your day. That number is a callback. But you're trusting the shop: they might call twice, call the wrong number, or never call at all. And if you need the shoe, then the matching socks, then a gift box, each from a different shop that calls you before you can ask the next one, it gets complicated fast.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Callback
A function you hand to other code so it can call it later.
Async callback
A callback that runs later, after something finishes, not right away.
Error-first callback
A callback whose first parameter is an error (or null) and whose second is the result: (err, data).
Callback hell
Deeply nested callbacks, each inside the last, that become hard to read and to handle errors in.
Inversion of control
Giving your next step to someone else's code and trusting it to call it correctly.
Promisify
Wrap a callback-style function so it returns a promise instead.

Step by step

01A function that calls you back

loadUser pretends to talk to a server (with a timer). It takes an id and a callback, and calls the callback later with an error or the user.

load-user.jswhole filejavascript
function loadUser(id, callback) {
  setTimeout(() => {
    if (id <= 0) {
      callback(new Error("Invalid id: " + id));
    } else {
      callback(null, { id, name: "User " + id });
    }
  }, 100);
}

02Handle the error first

Check err first and return, so the success code below only runs when there is data. Forgetting the return is a classic bug: you log the error and then crash reading user.name.

use-user.jswhole filejavascript
loadUser(7, (err, user) => {
  if (err) {
    console.log("Could not load:", err.message);
    return;
  }
  console.log("Hello,", user.name);
});

03Steps that depend on each other nest

Each step needs the previous result, so it goes inside the previous callback, with its own error check.

pyramid.jswhole filejavascript
loadUser(7, (err, user) => {
  if (err) return showError(err);
  loadOrders(user.id, (err, orders) => {
    if (err) return showError(err);
    loadDetails(orders[0], (err, details) => {
      if (err) return showError(err);
      show(details);           // three levels deep
    });
  });
});
Steps that depend on each other nestdiagram
Rendering diagram…

04Flatten with named functions

Giving each step a name and passing it along removes the pyramid. The control flow is the same, so error handling and trust problems remain, but it's much easier to read.

flat.jswhole filejavascript
loadUser(7, gotUser);

function gotUser(err, user) {
  if (err) return showError(err);
  loadOrders(user.id, gotOrders);
}
function gotOrders(err, orders) {
  if (err) return showError(err);
  loadDetails(orders[0], gotDetails);
}
function gotDetails(err, details) {
  if (err) return showError(err);
  show(details);
}

05Protect yourself from bad callers

If you must use a library you don't trust to call you exactly once, wrap your callback so only the first call counts. Promises give you this rule for free.

Try it yourself

  1. 1

    Break the error-first rule

    In the first example, remove the return; inside if (err). Predict what the second request logs now, then run it.

  2. 2

    Make a step fail

    In the callback-hell example, change the second later(...) so its callback receives an error: setTimeout(() => callback(new Error("orders down")), 50) needs a new helper. Write function failLater(callback) { ... } and use it. Which steps print?

  3. 3

    Catch it in the right place

    In the last example, move the try...catch inside the timer callback around the throw. Predict the output, then check.

Code & diagrams

Error-first callbacks New tab

The results arrive later, inside the callback. The same `handle` function deals with both outcomes because the error always comes first.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

Both requests started
Loaded: { id: 7, name: 'User 7' }
Failed: Invalid id: -1
Callback hell: three dependent steps New tab

Three steps, three levels of nesting and three copies of the error check. Topic 10.6 rewrites this as a flat promise chain, and Topic 10.8 with `await`.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

Got user Asha
Got 2 orders
First order total: 250
Inversion of control: a callback called twice New tab

`once` uses a closure (Topic 3.6) to remember whether it already ran. A promise settles only once by design, so this guard comes built in.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

Without guard: charged 100
Without guard: charged 100
With once(): charged 200
try...catch can't catch an error in a later callback New tab

When the callback throws, the `try` is no longer on the call stack, so nothing catches it. Async errors must be passed along (as `err`) or handled with promises.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

try block finished without errors
Uncaught Error: thrown inside the timer callback

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Use the result outside the callback

Write let user; loadUser(7, (err, u) => { user = u; }); console.log(user.name);.

terminal
$ Console
── what you'll see ──
Uncaught TypeError: Cannot read properties of undefined (reading 'name')

Break #2

Forget to return after an error

In an error-first callback, write if (err) console.log(err.message); with no return, then console.log(user.name);.

terminal
$ Console
── what you'll see ──
Invalid id: -1
Uncaught TypeError: Cannot read properties of undefined (reading 'name')

Myth vs fact

Myth

Callbacks are always asynchronous.

Fact

A callback is only a function you pass. map, filter and forEach call theirs synchronously; setTimeout and event listeners call theirs later.

Myth

Callbacks are obsolete now that we have promises.

Fact

Promises suit one-off results. Repeating events (clicks, messages, setInterval) still use callbacks, and so do array methods. Many promise-based APIs are built on callbacks underneath.

Myth

Wrapping an async call in try...catch catches its errors.

Fact

Only errors thrown while the try block is running are caught. A callback that runs later throws on a fresh call stack, outside your try.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    Node.js ships util.promisify(fn) to turn any error-first callback function into a promise-returning one, and most core modules have promise versions (fs/promises). In the browser, you write the wrapper yourself: new Promise((resolve, reject) => fn(arg, (err, data) => err ? reject(err) : resolve(data))).

  • ▸

    An API should call its callback always synchronously or always asynchronously, never both. Mixed behaviour ("Zalgo") makes ordering bugs that only appear with a cache hit. If a result is already available, defer the callback with queueMicrotask to keep it consistent; promises do this automatically.

  • ▸

    Callbacks hold on to whatever their closure captures. An event listener or interval that's never removed keeps its closure (and maybe large DOM trees) in memory: a common leak in single-page apps. Use { once: true }, AbortController signals on addEventListener, and clear intervals on teardown.

Remember this

  1. 1

    A callback is a function passed as an argument to another function, to be called later. You've used them already: addEventListener("click", handler), setTimeout(fn, 100), array.map(fn) (Topic 3.7). map's callback runs synchronously, right away. An async callback runs later, after something finishes, so the result is only available inside it.

  2. 2

    An async function written in the callback style takes the callback as its last argument and returns nothing useful. Everything that needs the result goes inside the callback. Code after the call runs straight away, before the result exists.

  3. 3

    Error-first callbacks are the convention Node.js made standard: the callback's first parameter is an error (or null if all went well) and the result comes second: (err, data) => { ... }. Always check err first and return after handling it, so you don't go on to use data that isn't there. You can't use try...catch around the outer call to catch async errors: by the time the error happens, that try block has long finished.

  4. 4

    Callback hell (or the "pyramid of doom") is what happens when each step needs the previous result: get the user, then their orders, then the first order's details. Each step nests inside the last, the code drifts to the right, every level needs its own error check, and running steps in parallel or adding a timeout becomes very hard. Naming the functions and keeping them flat helps, but doesn't fix the deeper problems.

  5. 5

    The deeper problem is inversion of control: you hand your continuation to someone else's code and trust it. A buggy or untrusted library might call your callback twice (charging a card twice), never, too early (synchronously sometimes and asynchronously other times, a bug nicknamed "releasing Zalgo"), or swallow your errors. You can guard against each case (for example, a once wrapper), but you have to remember every time.

  6. 6

    Promises (Topic 10.5) were created to fix this. A promise is a value you hold that can settle only once, always calls you back asynchronously, passes errors along a chain, and can be combined (Topic 10.7). Callbacks are still the right tool for events that happen many times (clicks, messages), and many older APIs still use them. You can wrap a callback API in a promise; this is called promisifying.

Explain it without notes

01

What is an error-first callback, and why does that convention exist?

02

What is callback hell, and what problems does it cause beyond indentation?

03

Explain inversion of control with callbacks, and how promises address it.

04

Why can't try...catch around setTimeout catch an error thrown in its callback?

Practice

01

Write divideLater(a, b, callback) that, after 50 ms, calls callback(new Error("Cannot divide by zero")) if b is 0, otherwise callback(null, a / b). Call it for 10 / 2 and 1 / 0.

02

Wrap divideLater in divideAsync(a, b) that returns a promise, and use it with .then and .catch.

Trade-offs

  • ↔

    Callbacks have zero overhead and fit repeating events perfectly; promises add a small cost per step but give one-time settling, error propagation and combinators for one-off results.

  • ↔

    Flattening callbacks with named functions is a cheap readability fix for old code; converting to promises and async/await is more work but fixes error handling and trust as well.

Done when you can

  • I can write and call a function that takes an async callback.

  • I follow the error-first convention and return after handling an error.

  • I can recognise callback hell and flatten it with named functions.

  • I can explain inversion of control and guard a callback with once.

  • I know try...catch doesn't catch errors thrown in later callbacks.