---
title: Callbacks
source: 'https://academia.sh/en/courses/asynchronous-javascript/callbacks'
course: 'Asynchronous JavaScript and the Runtime'
language: en
updated: '2026-08-17T18:09:43+00:00'
license: 'CC BY-SA 4.0'
---

# Callbacks

The error-first callback contract, setting up sequential and combined processing by hand, the nesting problem, and the uncertainties that come from handing control to the callee.

Timers bound the callback to time. In a real source, what is bound is not time but a
result: notify once the measurement is ready, report why if not. This is asynchronous
programming's first widespread interface, and understanding promises requires first
seeing this interface's limits.

This lesson turns the measurement source into a callback-based interface, reads three
stations first in sequence and then together, and shows where this approach falls
short.

## The Error-First Contract

An asynchronous function cannot hand back its result with `return`, because the result
does not exist yet when the function returns. Instead, it hands the result over through
a **callback**.

The error has to come through the same path: as seen in the previous lesson, an error
thrown from inside a callback does not reach the caller's `try` block. The established
fix is to set aside the callback's first parameter for the error — the **error-first
callback** contract.

```js
const SOURCE = {
  A1: { delay: 30, value: 21.4 },
  B2: { delay: 10, value: 19.8 },
  C3: { delay: 20, value: 23.1 },
};

function fetchMeasurement(station, callback) {
  const record = SOURCE[station];
  if (record === undefined) {
    setTimeout(() => callback(new Error(`unknown station: ${station}`)), 0);
    return;
  }
  setTimeout(() => callback(null, { station, value: record.value }), record.delay);
}

fetchMeasurement("A1", (error, measurement) => {
  if (error) {
    console.log("A1 error:", error.message);
    return;
  }
  console.log("A1 measurement:", measurement.value);
});

fetchMeasurement("Z9", (error, measurement) => {
  if (error) {
    console.log("Z9 error:", error.message);
    return;
  }
  console.log("Z9 measurement:", measurement.value);
});

console.log("requests registered");
```

```
requests registered
Z9 error: unknown station: Z9
A1 measurement: 21.4
```

Three details are part of the contract. First, even on the error path, the callback is
called asynchronously; even the unknown-station error was left to the next turn with
`setTimeout`. Second, when there is an error, the second parameter is meaningless.
Third, error checking is written by hand at every call site — a cost repeated at the
end of the lesson.

## Sequential Processing and Nesting

Suppose you want to read three stations in sequence and compute the average. Because
each step's result is only available inside the next step's callback, the steps nest
inside one another.

```js
const SOURCE = {
  A1: { delay: 30, value: 21.4 },
  B2: { delay: 10, value: 19.8 },
  C3: { delay: 20, value: 23.1 },
};

function fetchMeasurement(station, callback) {
  const record = SOURCE[station];
  if (record === undefined) {
    setTimeout(() => callback(new Error(`unknown station: ${station}`)), 0);
    return;
  }
  setTimeout(() => callback(null, { station, value: record.value }), record.delay);
}

fetchMeasurement("A1", (errorA, a) => {
  if (errorA) {
    console.log("error:", errorA.message);
    return;
  }
  fetchMeasurement("B2", (errorB, b) => {
    if (errorB) {
      console.log("error:", errorB.message);
      return;
    }
    fetchMeasurement("C3", (errorC, c) => {
      if (errorC) {
        console.log("error:", errorC.message);
        return;
      }
      const average = (a.value + b.value + c.value) / 3;
      console.log("order:", [a.station, b.station, c.station].join(" -> "));
      console.log("average:", average.toFixed(2));
    });
  });
});
```

```
order: A1 -> B2 -> C3
average: 21.43
```

The program is correct, but three observations can be made. Indentation grows with the
number of steps, not with logical complexity; ten steps means ten times the
indentation. Error checking is repeated three times, identically each time. Last, the
code that will use the flow's result stays at the innermost level; adding a step
requires touching everything from the outermost level to the innermost.

This layout's common name is the callback pyramid. The real problem is not its
appearance, it is that it is **not composable**: there is no general operator that
combines two asynchronous steps; every combination is written by hand.

## Combined Processing: The Cost of Coordination

If the three stations are independent of each other, waiting for them in sequence is
unnecessary; all three can be started together. In this case, gathering the results
requires a coordinating function that keeps a counter and a flag.

```js
const SOURCE = {
  A1: { delay: 30, value: 21.4 },
  B2: { delay: 10, value: 19.8 },
  C3: { delay: 20, value: 23.1 },
};

function fetchMeasurement(station, callback) {
  const record = SOURCE[station];
  if (record === undefined) {
    setTimeout(() => callback(new Error(`unknown station: ${station}`)), 0);
    return;
  }
  setTimeout(() => callback(null, { station, value: record.value }), record.delay);
}

function fetchAll(stations, callback) {
  const results = new Array(stations.length);
  let remaining = stations.length;
  let done = false;

  stations.forEach((station, index) => {
    fetchMeasurement(station, (error, measurement) => {
      if (done) return;
      if (error) {
        done = true;
        callback(error);
        return;
      }
      console.log("arrived:", station);
      results[index] = measurement;
      remaining -= 1;
      if (remaining === 0) {
        done = true;
        callback(null, results);
      }
    });
  });
}

fetchAll(["A1", "B2", "C3"], (error, measurements) => {
  if (error) {
    console.log("error:", error.message);
    return;
  }
  console.log("result order:", measurements.map((m) => m.station).join(" "));
});
```

```
arrived: B2
arrived: C3
arrived: A1
result order: A1 B2 C3
```

Arrival order followed the delays — B2, C3, A1 — yet the result array kept the request
order. What makes this possible is placing results by index; using position instead of
relying on order is the basic pattern in asynchronous aggregation.

The `done` flag is also necessary: it stops results arriving after an error from
triggering the callback a second time. So a correct "wait for all" function keeps two
separate pieces of state — a counter and a flag. This job will move into the library
along with promises and shrink to a single line.

## Handing Control to the Callee

When you give a callback, you do not decide when and how many times your function will
be called; the code you called decides. This is called **inversion of control**, and it
produces three concrete uncertainties.

**The callback might never be called.** If the source does not respond, nothing
happens; the program sees neither a result nor an error, it just silently waits. A
timeout has to be set up separately to close this gap.

**The callback might be called more than once.** The contract forbids this, but the
language does not enforce it.

```js
function buggyFetchMeasurement(station, callback) {
  setTimeout(() => {
    callback(null, { station, value: 21.4 });
    callback(null, { station, value: 21.4 });
  }, 5);
}

let count = 0;
let total = 0;

buggyFetchMeasurement("A1", (error, measurement) => {
  count += 1;
  total += measurement.value;
  console.log("count:", count, "total:", total.toFixed(1));
});
```

```
count: 1 total: 21.4
count: 2 total: 42.8
```

A single measurement was counted twice. The fault is in the source, not the side doing
the counting; but the side doing the counting bears the consequence.

**The callback can sometimes be called synchronously.** Calling the callback directly
on a cache hit, on the thinking that there is no need to wait, changes the flow's
order.

```js
const SOURCE = { B2: { delay: 10, value: 19.8 } };

function fetchMeasurement(station, callback) {
  const record = SOURCE[station];
  setTimeout(() => callback(null, { station, value: record.value }), record.delay);
}

const cache = new Map();

function cachedFetch(station, callback) {
  if (cache.has(station)) {
    callback(null, cache.get(station));
    return;
  }
  fetchMeasurement(station, (error, measurement) => {
    cache.set(station, measurement);
    callback(error, measurement);
  });
}

let status = "preparing";

cachedFetch("B2", (error, measurement) => {
  console.log("1 — first call, status:", status);
  status = "processing";
  cachedFetch("B2", () => {
    console.log("2 — call from cache, status:", status);
  });
  status = "after inner call";
  console.log("3 — line after the inner call, status:", status);
});

status = "ready";
```

```
1 — first call, status: ready
2 — call from cache, status: processing
3 — line after the inner call, status: after inner call
```

The second call's callback ran **before** the line right after it, because it was
called synchronously on the cache hit. Same interface, same call, two different
orders. The rule is clear: an interface should not call its callback sometimes
synchronously and sometimes asynchronously. Even on a cache hit, the result should be
left for the next turn.

## The Gap Callbacks Leave

The programs in this lesson run and none of them contains a language error. Yet all of
them share the same gaps: the error path is written by hand at every level,
`try`/`catch` is useless, there is no general operator for combining steps, and
contract violations (never calling, calling twice, calling synchronously) are not
prevented by the language.

The result is that an asynchronous operation is not a **first-class value**. A callback
notifies the side that gets the result; but there is no object representing the
operation itself — one that can be stored, returned, or handed to someone else.

## Summary

- An asynchronous function cannot return its result; the error-first callback contract
  delivers both the result and the error through a single path.
- Sequential steps nest inside callbacks; indentation and error checking grow with the
  number of steps.
- Gathering requests that run together requires a counter and a flag; results are
  placed by position, not by arrival order.
- Inversion of control produces three uncertainties: the callback never being called,
  being called more than once, and sometimes being called synchronously.
- In the callback arrangement, an asynchronous operation is not a first-class value;
  this is why it cannot be composed.

## Next Step

This gap's answer is representing "an operation whose result is not yet known" as a
value. The next lesson defines that value: the promise. A promise's three states, states
being able to change only once, callbacks being taken into a microtask, and chaining
producing a new promise at every step — all four of this lesson's gaps will follow from
these rules.
