Skip to content
academia.sh

Lesson 04 / 17

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.

Contents

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.

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.

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.

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.

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.

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.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close