You already know how the event loop spins. What you have not seen yet is how work is handed to it. The answer, in classic Node.js and still today across much of its API, is the callback: a function you write that Node stores away to call later, when the work is ready.

This lesson teaches the pattern from scratch. It is not ancient history: even though day to day you will write async/await, callbacks are still underneath everything — streams use them, so do EventEmitters, the HTTP server and dozens of Node APIs that never got a promise-based version — and understanding their rules is the only way to understand what exactly promises solve in the next lesson.

You are going to learn the canonical shape of a callback in Node (the error-first callback), you are going to write your own asynchronous functions for Escena Viva, you are going to discover why try/catch stops working the moment asynchrony is involved, and you are going to build with your own hands — on purpose — the dreaded pyramid of doom. Feeling that discomfort first-hand is the best possible preparation for the next lesson.

Contents

  1. What a callback is
  2. Synchronous versus asynchronous: the same problem, two shapes
  3. Node's canonical pattern: the error-first callback
  4. Writing your own asynchronous functions
  5. The golden rule: exactly once, and always asynchronously
  6. Why try/catch does not catch asynchronous errors
  7. The pyramid of doom in Escena Viva
  8. Classic mitigation techniques
  9. Advantages and drawbacks of callbacks
  10. Why callbacks still matter

  1. What a callback is

A callback is simply a function passed as an argument to another function so that the latter can call it at some point. It is not a Node concept nor an asynchrony concept: it is a consequence of the fact that in JavaScript functions are values just like numbers or strings.

In fact you have been using callbacks since Module 1 without calling them that:

// SYNCHRONOUS callbacks: map, filter and sort call your function
// immediately, as many times as needed, and return the result.

const titles = catalog.map((event) => event.title);
const largeVenue = catalog.filter((event) => event.venue === 'Auditorio Ribera');
const sorted = [...catalog].sort((a, b) => a.title.localeCompare(b.title));

These are synchronous callbacks: by the time map returns, your function has already run as many times as necessary. No event loop is involved.

The interesting ones for us are the asynchronous ones:

// ASYNCHRONOUS callback: it is registered now and runs later,
// when the event loop reaches the corresponding phase.

setTimeout(() => {
  console.log('This runs afterwards');
}, 1000);

console.log('This runs first');

The difference is fundamental and defines the whole lesson:

Synchronous callback Asynchronous callback
When it runs Before the function that received it returns Afterwards, from the event loop
Examples map, filter, reduce, sort, forEach setTimeout, fs.readFile, server.on('request')
Can it be wrapped in try/catch? Yes No (section 6)
Can it return a useful value? Yes, to its caller No: the caller returned long ago

  1. Synchronous versus asynchronous: the same problem, two shapes

Let's state a concrete Escena Viva problem: finding an event by its identifier. Today the data is in memory, but in Module 3 it will be in data/events.json and in Module 7 in a database. That is: today it is instant, tomorrow it will involve waiting.

The synchronous version, the one you could write without this course:

// src/lab/find-sync.js

const catalog = [
  { id: 'evt-001', title: 'Concierto de Otono', venue: 'Teatro Almendra' },
  { id: 'evt-002', title: 'Noche de Monologos', venue: 'Sala Boveda' },
  { id: 'evt-003', title: 'Festival de Jazz de Primavera', venue: 'Auditorio Ribera' }
];

function findEventSync(id) {
  const event = catalog.find((e) => e.id === id);
  if (!event) {
    throw new Error(`Event not found: ${id}`);
  }
  return event;
}

// Usage: natural, linear, with a try/catch that works.
try {
  const event = findEventSync('evt-002');
  console.log(`Found: ${event.title}`);
} catch (error) {
  console.error(`Error: ${error.message}`);
}

Everything fits: the value is returned, the error is thrown, and try/catch picks it up. It is the mental model you learned to program with.

Now the asynchronous version, which is what you will need as soon as the data comes from disk or from the network:

// src/lab/find-async.js

const catalog = [
  { id: 'evt-001', title: 'Concierto de Otono', venue: 'Teatro Almendra' },
  { id: 'evt-002', title: 'Noche de Monologos', venue: 'Sala Boveda' },
  { id: 'evt-003', title: 'Festival de Jazz de Primavera', venue: 'Auditorio Ribera' }
];

// The result is NOT returned: it is handed to the callback.
// The error is NOT thrown: it is handed to the callback as the first argument.
function findEvent(id, callback) {
  // setTimeout simulates the latency of a disk or a database.
  setTimeout(() => {
    const event = catalog.find((e) => e.id === id);
    if (!event) {
      callback(new Error(`Event not found: ${id}`));
      return;
    }
    callback(null, event);
  }, 50);
}

// Usage: the result arrives "inwards" into the function, not "outwards".
findEvent('evt-002', (error, event) => {
  if (error) {
    console.error(`Error: ${error.message}`);
    return;
  }
  console.log(`Found: ${event.title}`);
});

console.log('This line prints BEFORE the result');
This line prints BEFORE the result
Found: Noche de Monologos

Compare the two versions carefully, because the shift in mindset is everything:

Aspect Synchronous Asynchronous with callback
Delivering the result return event callback(null, event)
Delivering the error throw new Error(...) callback(new Error(...))
Code flow Top to bottom Fragmented: the "afterwards" lives inside the callback
Catching errors try/catch Check the first argument
While waiting The process is blocked The process serves other things

That last row is the whole reason this exists. If findEvent had to read from disk, the synchronous version would freeze the Escena Viva server for the entire read; the asynchronous one leaves the thread free to serve other buyers.

  1. Node's canonical pattern: the error-first callback

Node did not leave the shape of the callback to everyone's imagination. It fixed a convention that all of its standard library and practically the whole ecosystem follows:

The callback receives the error as its first argument and the result as its second. If there was no error, the first argument is null.

function (err, result) { /* ... */ }

It is called an error-first callback or Node-style callback. This is how it looks in the real API:

const fs = require('node:fs');

fs.readFile('data/events.json', 'utf8', (error, content) => {
  if (error) {
    console.error(`Could not read the catalog: ${error.message}`);
    return;
  }
  const catalog = JSON.parse(content);
  console.log(`Loaded ${catalog.length} events`);
});

The complete rules of the convention:

  1. The callback is always the last parameter of the function.
  2. The callback's first argument is always the error, or null if everything went well.
  3. The error is an Error object, not a string. An Error carries message, stack and — in Node — often a code ('ENOENT', 'EACCES') that lets you decide without parsing the text.
  4. If there is an error, there is no result. It is never called with both at once.

And the usage pattern, which you should write on autopilot:

asyncFunction(args, (error, result) => {
  if (error) {
    // 1. Handle the error
    // 2. RETURN. This return is mandatory.
    return;
  }
  // 3. Here, and only here, the result is trustworthy
});

The return after handling the error is not optional. Without it, execution carries on into the happy-path code with result being undefined, and the real failure ends up buried under a TypeError: Cannot read properties of undefined. It is, without exaggeration, the number one mistake of anyone starting with callbacks.

Why the error first and not the result?

Because it forces you to see it. The error occupies the first position in the signature, so it shows up in every callback you write whether you want it or not. If it were at the end, it would be trivial to declare (result) => {...} and never find out that something can fail.

It is a deliberate design decision: make the right path the easy path.

  1. Writing your own asynchronous functions

We are going to build Escena Viva's asynchronous data layer. We will use setTimeout to simulate latency that in Module 3 will be real (disk) and in Module 7 will be network (database). This is not a gratuitous teaching trick: it is exactly what a test double does in Module 9.

Create src/lab/async-data.js:

// src/lab/async-data.js
// Escena Viva data access layer with Node-style callbacks.
// The latency is simulated with setTimeout; in module 3 it will be real I/O.

const catalog = [
  {
    id: 'evt-001',
    title: 'Concierto de Otono',
    venue: 'Teatro Almendra',
    organizer: 'org-almendra',
    sessions: [
      { id: 'ses-001-1', dateTime: '2026-10-03T20:00:00', capacity: 420, sold: 180, priceCents: 2500 },
      { id: 'ses-001-2', dateTime: '2026-10-04T19:00:00', capacity: 420, sold: 96,  priceCents: 2200 }
    ]
  },
  {
    id: 'evt-002',
    title: 'Noche de Monologos',
    venue: 'Sala Boveda',
    organizer: 'org-boveda',
    sessions: [
      { id: 'ses-002-1', dateTime: '2026-10-10T21:30:00', capacity: 120, sold: 118, priceCents: 1800 },
      { id: 'ses-002-2', dateTime: '2026-10-11T21:30:00', capacity: 120, sold: 45,  priceCents: 1800 },
      { id: 'ses-002-3', dateTime: '2026-10-17T21:30:00', capacity: 120, sold: 12,  priceCents: 1500 }
    ]
  },
  {
    id: 'evt-003',
    title: 'Festival de Jazz de Primavera',
    venue: 'Auditorio Ribera',
    organizer: 'org-ribera',
    sessions: [
      { id: 'ses-003-1', dateTime: '2027-04-17T19:00:00', capacity: 900, sold: 640, priceCents: 3800 },
      { id: 'ses-003-2', dateTime: '2027-04-18T19:00:00', capacity: 900, sold: 720, priceCents: 4200 }
    ]
  }
];

// Simulated latency, in milliseconds.
const LATENCY_MS = 40;

// --- Looking up an event by its id ---
function findEvent(id, callback) {
  setTimeout(() => {
    const event = catalog.find((e) => e.id === id);

    if (!event) {
      // Error with a code, just like Node does: lets you decide without reading the text.
      const error = new Error(`Event not found: ${id}`);
      error.code = 'EVENT_NOT_FOUND';
      callback(error);
      return;
    }

    callback(null, event);
  }, LATENCY_MS);
}

// --- Looking up a session by its id, across the whole catalog ---
function findSession(sessionId, callback) {
  setTimeout(() => {
    for (const event of catalog) {
      const session = event.sessions.find((s) => s.id === sessionId);
      if (session) {
        // We also return the event: the caller almost always needs it.
        callback(null, { event, session });
        return;
      }
    }

    const error = new Error(`Session not found: ${sessionId}`);
    error.code = 'SESSION_NOT_FOUND';
    callback(error);
  }, LATENCY_MS);
}

// --- Reserving tickets ---
function reserveTickets(sessionId, quantity, callback) {
  // Argument validation BEFORE any work.
  if (!Number.isInteger(quantity) || quantity < 1) {
    const error = new Error('The quantity must be a positive integer');
    error.code = 'INVALID_QUANTITY';
    // Careful: there is a trap here. We fix it in section 5.
    callback(error);
    return;
  }

  findSession(sessionId, (error, result) => {
    if (error) {
      callback(error);
      return;
    }

    const { event, session } = result;
    const available = session.capacity - session.sold;

    if (quantity > available) {
      const failure = new Error(
        `Insufficient capacity in ${sessionId}: you asked for ${quantity} and ${available} are left`
      );
      failure.code = 'INSUFFICIENT_CAPACITY';
      callback(failure);
      return;
    }

    // Since the user's JavaScript is single-threaded, this read-modify-write
    // is atomic: nobody can slip in between the two lines.
    session.sold += quantity;

    callback(null, {
      eventId: event.id,
      sessionId: session.id,
      quantity,
      amountCents: quantity * session.priceCents,
      availableRemaining: session.capacity - session.sold
    });
  }, LATENCY_MS);
}

Try it out:

// Happy path
reserveTickets('ses-002-2', 3, (error, reservation) => {
  if (error) {
    console.error(`Could not reserve: ${error.message}`);
    return;
  }
  console.log(
    `Reserved ${reservation.quantity} tickets for ${reservation.sessionId} ` +
    `for ${(reservation.amountCents / 100).toFixed(2)} EUR. ` +
    `${reservation.availableRemaining} left.`
  );
});

// Insufficient capacity path: ses-002-1 has 118 of 120 sold
reserveTickets('ses-002-1', 5, (error) => {
  if (error) {
    console.error(`[${error.code}] ${error.message}`);
  }
});
Reserved 3 tickets for ses-002-2 for 54.00 EUR. 72 left.
[INSUFFICIENT_CAPACITY] Insufficient capacity in ses-002-1: you asked for 5 and 2 are left

Notice two design details that will recur throughout the course:

  • error.code alongside error.message. The message is for people; the code is for the program. In Module 6 that code will be translated into an HTTP status: SESSION_NOT_FOUND → 404, INSUFFICIENT_CAPACITY → 409.
  • findSession returns { event, session }. Returning the information the caller is going to need anyway avoids a second lookup.

  1. The golden rule: exactly once, and always asynchronously

Writing functions that take callbacks is easy. Writing functions that accept callbacks correctly has two rules that admit no exception.

Rule 1: call the callback exactly once

Not zero times (your caller waits forever) nor twice (the code afterwards runs twice). This is the cause of the classic bug we already mentioned:

// WRONG: without a return, the callback is called TWICE when there is an error.
function findEventWrong(id, callback) {
  setTimeout(() => {
    const event = catalog.find((e) => e.id === id);
    if (!event) {
      callback(new Error('Not found'));       // Missing return
    }
    callback(null, event);                    // Runs anyway
  }, 40);
}

findEventWrong('evt-999', (error, event) => {
  if (error) {
    console.error('Error handled');
    return;
  }
  console.log(event.title);   // TypeError: Cannot read properties of undefined
});
Error handled
TypeError: Cannot read properties of undefined (reading 'title')

The error was handled correctly… and the process failed anyway, because the callback was invoked a second time with undefined. One return after every call to the callback. No exceptions.

Rule 2: always call the callback asynchronously

This one is subtler and far more treacherous. Look again at the validation in reserveTickets:

function reserveTickets(sessionId, quantity, callback) {
  if (!Number.isInteger(quantity) || quantity < 1) {
    callback(error);   // <-- Called SYNCHRONOUSLY
    return;
  }
  findSession(sessionId, (...) => {
    callback(null, result);   // <-- Called ASYNCHRONOUSLY
  });
}

We have a function that is sometimes synchronous and sometimes asynchronous. And that breaks things:

// src/lab/zalgo.js
// Shows the danger of a "sometimes synchronous" function.

let state = 'uninitialized';

reserveTickets('ses-002-2', -1, (error) => {
  console.log(`  Inside the callback, state = "${state}"`);
});

state = 'initialized';
console.log(`After the call, state = "${state}"`);
  Inside the callback, state = "uninitialized"
After the call, state = "initialized"

The callback ran before the next line had executed. With a valid quantity, however, the order would have been the opposite. The same function produces two different execution orders depending on its arguments.

This problem has its own name in the Node community — "don't release Zalgo", after a classic article by Isaac Schlueter — and it produces the worst kind of bugs: intermittent, data-dependent, impossible to reproduce.

The remedy is process.nextTick, and this is exactly one of the two legitimate cases we announced in the event loop lesson:

// RIGHT: the callback is ALWAYS invoked asynchronously.
function reserveTickets(sessionId, quantity, callback) {
  if (!Number.isInteger(quantity) || quantity < 1) {
    const error = new Error('The quantity must be a positive integer');
    error.code = 'INVALID_QUANTITY';

    // We defer the call to guarantee uniform asynchrony.
    process.nextTick(() => callback(error));
    return;
  }

  // ... the rest unchanged
}

Now the output is always the same, with any argument:

After the call, state = "initialized"
  Inside the callback, state = "initialized"
Situation What to use
Immediate error path (argument validation) process.nextTick(() => callback(error))
A result you already have in memory or in cache process.nextTick(() => callback(null, value))
Long work that has to be chunked setImmediate

A professional note. Node's standard library follows this rule scrupulously. fs.readFile with a non-existent path does not call you back synchronously: it defers the error. When you write an asynchronous API of your own, follow it too. It is the difference between a library you can trust and one that gives you nasty surprises.

  1. Why try/catch does not catch asynchronous errors

This is the deep reason the error-first convention exists. Let's demonstrate it.

// src/lab/broken-try-catch.js
// try/catch does NOT catch what is thrown inside an asynchronous callback.

function operationThatFails(callback) {
  setTimeout(() => {
    throw new Error('Failure inside the asynchronous callback');
  }, 50);
}

try {
  operationThatFails();
  console.log('The try has finished without catching anything');
} catch (error) {
  console.error('This is NEVER printed:', error.message);
}

console.log('The script carries on...');
The try has finished without catching anything
The script carries on...

/path/src/lab/broken-try-catch.js:6
    throw new Error('Failure inside the asynchronous callback');
    ^
Error: Failure inside the asynchronous callback
    ...
[the process dies with code 1]

Why? Because the try block had already finished when the error was thrown. Remember the event loop:

sequenceDiagram
    participant P as Call stack
    participant B as Event loop
    P->>P: enters the try block
    P->>B: setTimeout schedules the callback
    P->>P: leaves the try block (already over!)
    P->>P: the stack empties
    Note over B: 50 ms go by
    B->>P: runs the callback (a NEW stack)
    P--xP: throw with no try around it
    Note over P: uncaught exception → the process dies

try/catch protects a region of the call stack, and the callback runs on a completely new stack, created by the event loop much later. There is no relationship between the two.

The practical consequence, which you must internalize:

Inside an asynchronous function, never throw an error outwards. Pass it to the callback.

// WRONG: nobody can catch this.
function reserveWrong(sessionId, quantity, callback) {
  setTimeout(() => {
    if (quantity > 10) throw new Error('Maximum 10 tickets per order');
    callback(null, { sessionId, quantity });
  }, 40);
}

// RIGHT: the error travels through the intended channel.
function reserveRight(sessionId, quantity, callback) {
  setTimeout(() => {
    if (quantity > 10) {
      callback(new Error('Maximum 10 tickets per order'));
      return;
    }
    callback(null, { sessionId, quantity });
  }, 40);
}

And an important variant: you can use try/catch inside the callback, because there you really are on the same stack.

fs.readFile('data/events.json', 'utf8', (error, content) => {
  if (error) {
    console.error(`Read error: ${error.message}`);
    return;
  }

  // JSON.parse is SYNCHRONOUS: here try/catch does work.
  let catalog;
  try {
    catalog = JSON.parse(content);
  } catch (parseError) {
    console.error(`The catalog is not valid JSON: ${parseError.message}`);
    return;
  }

  console.log(`Loaded ${catalog.length} events`);
});

As a last-resort safety net there is process.on('uncaughtException'), but it is not an error-handling mechanism: when it fires, your application's state is unknown. It is only good for logging the failure and shutting down gracefully. We will look at it in Module 11.

  1. The pyramid of doom in Escena Viva

Now the real problem. Escena Viva's complete purchase flow has four chained steps, and each one depends on the previous:

flowchart LR
    A["1. Find the event"] --> B["2. Check the session's<br/>capacity"]
    B --> C["3. Create the order<br/>status: pending"]
    C --> D["4. Issue the tickets<br/>and move to issued"]

With callbacks, "depends on the previous" means "goes inside the previous". And the result is this:

// src/lab/purchase-pyramid.js
// The complete purchase flow. It works, and it is a nightmare to maintain.

buyTickets('att-001', 'evt-002', 'ses-002-2', 3);

function buyTickets(userId, eventId, sessionId, quantity) {
  findEvent(eventId, (error, event) => {
    if (error) {
      console.error(`[purchase] the event was not found: ${error.message}`);
      return;
    }

    checkCapacity(sessionId, quantity, (error, session) => {
      if (error) {
        console.error(`[purchase] capacity: ${error.message}`);
        return;
      }

      createOrder(userId, session, quantity, (error, order) => {
        if (error) {
          console.error(`[purchase] could not create the order: ${error.message}`);
          return;
        }

        chargeOrder(order, (error, paidOrder) => {
          if (error) {
            // And on top of that we have to UNDO: release the reserved capacity.
            releaseCapacity(sessionId, quantity, (releaseError) => {
              if (releaseError) {
                console.error(`[purchase] failed to release capacity: ${releaseError.message}`);
              }
              console.error(`[purchase] charge declined: ${error.message}`);
            });
            return;
          }

          issueTickets(paidOrder, (error, tickets) => {
            if (error) {
              console.error(`[purchase] could not issue the tickets: ${error.message}`);
              return;
            }

            console.log(`Order ${paidOrder.id} completed`);
            console.log(`Event : ${event.title}`);
            console.log(`Amount: ${(paidOrder.totalCents / 100).toFixed(2)} EUR`);
            for (const ticket of tickets) {
              console.log(`  ${ticket.code}  ${ticket.status}`);
            }
          });
        });
      });
    });
  });
}

This is called the pyramid of doom or callback hell, and it is not a matter of aesthetics. The problems are concrete and measurable:

Problem Why it hurts
Growing indentation With six levels, the useful code starts at column 30. It does not fit on normal screens
Repeated error handling The same if (error) { ...; return; } five times, and each one has to be written by hand
Undoing is hellish Look at the releaseCapacity nested inside the charge error: if there were three things to undo, that would be three more levels
Impossible to read in order To find out what happens after step 3 you have to scroll down, not keep reading
Hard to reuse None of those steps can be extracted without rewriting everything
Impossible to parallelize If steps 1 and 2 were independent, with this structure they would still run in series
Variables trapped in the closure event is only available inside its level; to use it further down you have to drag it through the whole pyramid

And the worst part is missing: this example is short. A real flow adds user validation, discount checking, audit logging and a confirmation email. Ten levels is no exaggeration.

  1. Classic mitigation techniques

Before promises, the community developed techniques to make this livable. They are still valid and good design practices in their own right.

8.1 Named functions instead of anonymous ones

Instead of nesting anonymous functions, each step is declared separately and passed by reference:

// src/lab/purchase-flat.js
// Same flow, flattened with named functions.

function buyTickets(userId, eventId, sessionId, quantity) {
  // Context shared by every step: it replaces the nested closures.
  const context = { userId, eventId, sessionId, quantity };

  findEvent(eventId, onEventFound);

  function onEventFound(error, event) {
    if (error) return fail('event', error);
    context.event = event;
    checkCapacity(sessionId, quantity, onCapacityChecked);
  }

  function onCapacityChecked(error, session) {
    if (error) return fail('capacity', error);
    context.session = session;
    createOrder(userId, session, quantity, onOrderCreated);
  }

  function onOrderCreated(error, order) {
    if (error) return fail('order', error);
    context.order = order;
    chargeOrder(order, onCharged);
  }

  function onCharged(error, paidOrder) {
    if (error) return compensateAndFail(error);
    context.order = paidOrder;
    issueTickets(paidOrder, onIssued);
  }

  function onIssued(error, tickets) {
    if (error) return fail('issuing', error);
    showSummary(context, tickets);
  }

  // A single error-handling point for the whole flow.
  function fail(step, error) {
    console.error(`[purchase] failure at step "${step}": ${error.message}`);
  }

  function compensateAndFail(error) {
    releaseCapacity(sessionId, quantity, (releaseError) => {
      if (releaseError) {
        console.error(`[purchase] CRITICAL: capacity not released: ${releaseError.message}`);
      }
      fail('charge', error);
    });
  }
}

Immediate gains:

  • Constant indentation. No level goes beyond two.
  • The flow reads top to bottom, in the same order in which it happens.
  • A single error-handling point (fail), not five copies.
  • Each step is a named function, which means it shows up by name in error traces and can be tested separately (Module 9).

The price is the context object: once you lose the nested closures you have to carry the state by hand. It is a reasonable trade.

8.2 Early return

We have already used it, but it deserves to be stated as a technique: handle the error and get out, instead of wrapping the happy path in an else.

// Worse: the happy path is indented and the elses pile up.
findEvent(id, (error, event) => {
  if (error) {
    console.error(error.message);
  } else {
    console.log(event.title);
  }
});

// Better: the happy path stays at the main level.
findEvent(id, (error, event) => {
  if (error) return console.error(error.message);
  console.log(event.title);
});

8.3 Modularize

If a function has more than two levels of callbacks, it is almost always doing more than one thing. In the purchase flow, createOrder + chargeOrder + compensate is a conceptual unit (process the payment) that can live in its own file and expose a single callback.

In the CommonJS Modules and require() lesson we will see how to do that separation properly. The underlying idea: the depth of the pyramid is usually a symptom of poor separation of responsibilities, not just of callback syntax.

8.4 A helper for running steps in series

When the steps have the same shape, you can write a small engine:

// src/utils/in-series.js
// Runs a list of steps in order, passing the context from one to the next.
// Each step has the signature: (context, next) => void
// where next is an error-first callback.

function inSeries(steps, context, onDone) {
  let index = 0;

  function next(error) {
    if (error) {
      onDone(error, context);
      return;
    }
    if (index >= steps.length) {
      onDone(null, context);
      return;
    }

    const step = steps[index++];

    // We defer to guarantee uniform asynchrony (golden rule 2).
    process.nextTick(() => step(context, next));
  }

  next();
}

module.exports = { inSeries };

Usage:

inSeries(
  [
    (ctx, next) => findEvent(ctx.eventId, (e, event) => {
      ctx.event = event;
      next(e);
    }),
    (ctx, next) => checkCapacity(ctx.sessionId, ctx.quantity, (e, session) => {
      ctx.session = session;
      next(e);
    }),
    (ctx, next) => createOrder(ctx.userId, ctx.session, ctx.quantity, (e, order) => {
      ctx.order = order;
      next(e);
    })
  ],
  { userId: 'att-001', eventId: 'evt-002', sessionId: 'ses-002-2', quantity: 3 },
  (error, context) => {
    if (error) return console.error(`[purchase] ${error.message}`);
    console.log(`Order ${context.order.id} created for ${context.event.title}`);
  }
);

This is, in essence, what the async library did, ubiquitous in the Node ecosystem between 2011 and 2016. If you come across async.series, async.waterfall or async.parallel in legacy code, now you know what they are.

And if it seems to you that this is still a lot of machinery for something that ought to be simple… you are absolutely right. That is exactly the conclusion the community reached, and it is why promises arrived.

  1. Advantages and drawbacks of callbacks

Advantages Drawbacks
Conceptual simplicity: it is just a function passed as an argument Nesting: sequential flows grow in depth
No performance cost: no intermediate object, no microtask queue Repetitive error handling: if (error) return at every level
Universal: supported by any version of Node and of the browser try/catch does not work: the language's error model does not apply
Perfect for repeated events: a callback can be called many times Easy to misuse: calling twice, not calling, calling synchronously
Required for streams and EventEmitter Composing is hard: chaining, parallelizing or cancelling needs helpers
Lower memory use in very frequent operations Inversion of control: you hand your function to a third party and trust it to call it properly

That last drawback deserves a note. When you pass a callback to a library, you are trusting it to call it once, with the right arguments and asynchronously. If the library has a bug, you suffer the consequences and debugging it is extremely hard. Promises eliminate this problem at the root, because the contract is imposed by the language and not by each author.

  1. Why callbacks still matter

With async/await available since Node 7.6, why devote a whole lesson to callbacks? Four very practical reasons:

1. There are Node APIs that only accept callbacks. Not everything has a promise-based version. fs.watch, dns.lookup, much of crypto, many child_process options and — above all — everything event-based are still callback territory.

2. EventEmitter is purely callback-based. When you write emitter.on('session-sold-out', (data) => {...}), that is a callback. And it cannot be a promise, because a promise settles once and an event is emitted many times. It is the topic of the Events and EventEmitter lesson.

3. Streams work with callbacks and events. The whole of Module 3 rests on stream.on('data', ...), stream.on('end', ...) and completion callbacks.

4. Express middleware is a callback. The (req, res, next) signature from Module 6 is exactly the pattern you have just learned, with next playing the role of "carry on with the next step".

Put another way: promises replace callbacks for single-result asynchrony, not for everything else. Knowing when to use each is part of writing Node professionally, and we will devote a decision table to it in the EventEmitter lesson.

Common Mistakes and Tips

Mistake 1: forgetting the return after handling the error. The callback gets called twice and the real failure ends up buried under a TypeError. It is the number one mistake.

Mistake 2: throwing exceptions inside an asynchronous callback. Nobody catches them; the process dies. Always pass them through the callback's first argument.

Mistake 3: wrapping an asynchronous call in try/catch and believing you are protected. The try has already finished when the callback runs.

Mistake 4: writing "sometimes synchronous" functions. The execution order changes with the data and irreproducible bugs appear. process.nextTick on the fast path.

Mistake 5: trying to return a value from a callback.

// This does NOT work: findEvent returns undefined long before.
function getTitle(id) {
  let title;
  findEvent(id, (error, event) => { title = event.title; });
  return title;   // undefined, always
}

There is no way to turn asynchronous into synchronous. The only way out is to propagate the asynchrony upwards.

Mistake 6: using callbacks inside forEach expecting the order to be respected. forEach waits for nothing. The callbacks finish in random order and there is no way to know when they all finished.

Tip 1: always write (error, result), not (err, res). In an Express handler, res means something very different, and the confusion is real.

Tip 2: give your errors a code. The message is for people; the code is what your code uses to decide.

Tip 3: if you are three levels of nesting deep, stop and extract functions. Do not keep writing towards the right.

Tip 4: don't rewrite working callback code just because it is fashionable. Learn to convert it when it makes sense — with util.promisify, in the next lesson — but an fs.watch with its callback needs no improvement at all.

Exercises

Exercise 1: fixing a broken asynchronous function

This function has four defects according to what you learned in the lesson. Find them all, explain the consequence of each one and rewrite it correctly.

function getOccupancy(sessionId, callback) {
  if (!sessionId) {
    callback('The session identifier is missing');
  }

  setTimeout(() => {
    const session = findSessionInMemory(sessionId);

    if (!session) {
      callback(new Error('Not found'));
    }

    try {
      const percentage = Math.round((session.sold / session.capacity) * 100);
      callback(null, percentage);
    } catch (error) {
      throw error;
    }
  }, 30);
}

Exercise 2: the sessions-at-risk report, asynchronously

In the Your First Node.js Program lesson you wrote a synchronous report of sessions with less than 20 % sold. Rewrite it with this lesson's asynchronous layer.

Write src/lab/risk-report.js with:

  1. A listSessionsAtRisk(thresholdPercent, callback) function that uses findEvent to load the three events one at a time, in series (the ids are evt-001, evt-002 and evt-003) and returns through the callback an array of { eventId, title, sessionId, dateTime, percentage } objects.
  2. Correct error handling: if any event fails, the callback must receive the error and must not be called again.
  3. Output to stdout with console.table and process.exitCode = 1 if there is any session at risk.
  4. The threshold must be passable on the command line: node src/lab/risk-report.js 25.

When you are done, answer this: how long does your solution take with a latency of 40 ms per event? How long would it take if the three events were loaded at once? Why can't you do that with this structure?

Exercise 3: flattening the purchase pyramid

Take the buyTickets flow from section 7 and implement it fully and runnably, with these simulated pieces (all with a latency of 30 ms and an error-first signature):

  • createOrder(userId, session, quantity, callback) → returns { id: 'ord-001', userId, sessionId, quantity, totalCents, status: 'pending' }.
  • chargeOrder(order, callback) → fails with code PAYMENT_DECLINED if totalCents > 20000; otherwise returns the order with status: 'paid'.
  • issueTickets(order, callback) → returns an array of quantity tickets { code: 'EV-2026-000001', sessionId, status: 'valid' } and leaves the order at status: 'issued'.
  • releaseCapacity(sessionId, quantity, callback) → returns the number of available seats after releasing.

Requirements:

  1. A flat structure, with named functions and a context object (technique 8.1).
  2. A single error-handling point.
  3. Correct compensation: if the charge fails, the capacity is released before reporting.
  4. Test both paths: ses-002-2 with 3 tickets (54.00 EUR, must succeed) and ses-003-2 with 5 tickets (210.00 EUR, must be declined and release the capacity).

Solutions

Solution 1

The four defects:

# Defect Consequence
1 Missing return after callback('The session identifier...') Execution carries on to the setTimeout and the callback is called twice
2 The error is a string, not an Error object Whoever receives it has no stack and no code; it also breaks the convention
3 Synchronous call on the validation path A "sometimes synchronous" function: Zalgo. The execution order changes with the arguments
4 Missing return after callback(new Error('Not found')) The try runs with session being undefined: TypeError

And a fifth bonus defect: the try/catch that does throw error is useless and harmful. It catches the exception only to rethrow it inside an asynchronous callback, where nobody can pick it up and where it will take the process down.

Corrected version:

// src/lab/get-occupancy.js
// Returns the occupancy percentage of a session.

function getOccupancy(sessionId, callback) {
  // 1. Argument validation, deferred to guarantee uniform asynchrony.
  if (!sessionId) {
    const error = new Error('The session identifier is missing');
    error.code = 'INVALID_ARGUMENT';
    process.nextTick(() => callback(error));
    return;
  }

  setTimeout(() => {
    const session = findSessionInMemory(sessionId);

    // 2. Error with a return: the callback is called exactly once.
    if (!session) {
      const error = new Error(`Session not found: ${sessionId}`);
      error.code = 'SESSION_NOT_FOUND';
      callback(error);
      return;
    }

    // 3. Real protection against inconsistent data, instead of a useless try/catch.
    if (!session.capacity || session.capacity <= 0) {
      const error = new Error(`Invalid capacity in ${sessionId}: ${session.capacity}`);
      error.code = 'INCONSISTENT_DATA';
      callback(error);
      return;
    }

    const percentage = Math.round((session.sold / session.capacity) * 100);
    callback(null, percentage);
  }, 30);
}

Solution 2

// src/lab/risk-report.js
// Sessions below the occupancy threshold, loading the events in series.

const EVENT_IDS = ['evt-001', 'evt-002', 'evt-003'];
const DEFAULT_THRESHOLD = 20;

function listSessionsAtRisk(thresholdPercent, callback) {
  const atRisk = [];
  let index = 0;
  let done = false;   // Guard against multiple calls to the callback.

  function nextEvent() {
    if (index >= EVENT_IDS.length) {
      finish(null, atRisk);
      return;
    }

    const eventId = EVENT_IDS[index++];

    findEvent(eventId, (error, event) => {
      if (error) {
        finish(error);
        return;
      }

      for (const session of event.sessions) {
        const percentage = Math.round((session.sold / session.capacity) * 100);
        if (percentage < thresholdPercent) {
          atRisk.push({
            eventId: event.id,
            title: event.title,
            sessionId: session.id,
            dateTime: session.dateTime,
            percentage
          });
        }
      }

      nextEvent();
    });
  }

  // Guarantees the callback is invoked exactly once.
  function finish(error, result) {
    if (done) return;
    done = true;
    callback(error, result);
  }

  nextEvent();
}

// --- Entry point ---
const threshold = Number(process.argv[2]) || DEFAULT_THRESHOLD;
const start = Date.now();

console.error(`Looking for sessions with less than ${threshold}% sold...`);

listSessionsAtRisk(threshold, (error, sessions) => {
  if (error) {
    console.error(`Could not generate the report: ${error.message}`);
    process.exitCode = 2;
    return;
  }

  console.error(`Queried ${EVENT_IDS.length} events in ${Date.now() - start} ms`);

  if (sessions.length === 0) {
    console.log('No sessions at risk.');
    return;
  }

  console.table(sessions);
  process.exitCode = 1;   // A non-zero code for an alerting system.
});
node src/lab/risk-report.js 20
Looking for sessions with less than 20% sold...
Queried 3 events in 128 ms
┌─────────┬───────────┬──────────────────────┬─────────────┬───────────────────────┬────────────┐
│ (index) │ eventId   │ title                │ sessionId   │ dateTime              │ percentage │
├─────────┼───────────┼──────────────────────┼─────────────┼───────────────────────┼────────────┤
│ 0       │ 'evt-002' │ 'Noche de Monologos' │ 'ses-002-3' │ '2026-10-17T21:30:00' │ 10         │
└─────────┴───────────┴──────────────────────┴─────────────┴───────────────────────┴────────────┘

Answers to the questions:

  • With 40 ms per event in series: ~120 ms. The times add up because each query starts when the previous one finishes.
  • If they were loaded at once: ~40 ms. The cost would be that of the slowest event, not the sum.
  • Why you cannot do it with this structure: nextEvent is built on the premise that each step calls the next one. To parallelize you would need a counter of outstanding responses, an indexed results array and a guard to call the final callback only when the counter reaches zero — and all of that without calling the callback twice if one fails. It is perfectly possible (it is what async.parallel did), but it is manual, error-prone machinery. In the next lesson, Promise.all solves exactly this in one line.

Solution 3

// src/lab/purchase-flat.js
// Escena Viva's complete purchase flow, with a flat structure and compensation.

const LATENCY_MS = 30;
let orderSequence = 0;
let ticketSequence = 0;

// --- Simulated steps ---

function createOrder(userId, session, quantity, callback) {
  setTimeout(() => {
    orderSequence++;
    callback(null, {
      id: `ord-${String(orderSequence).padStart(3, '0')}`,
      userId,
      sessionId: session.id,
      quantity,
      totalCents: quantity * session.priceCents,
      status: 'pending'
    });
  }, LATENCY_MS);
}

function chargeOrder(order, callback) {
  setTimeout(() => {
    if (order.totalCents > 20000) {
      const error = new Error(
        `Payment declined: ${(order.totalCents / 100).toFixed(2)} EUR exceeds the limit`
      );
      error.code = 'PAYMENT_DECLINED';
      callback(error);
      return;
    }
    callback(null, { ...order, status: 'paid' });
  }, LATENCY_MS);
}

function issueTickets(order, callback) {
  setTimeout(() => {
    const year = new Date().getFullYear();
    const tickets = [];
    for (let i = 0; i < order.quantity; i++) {
      ticketSequence++;
      tickets.push({
        code: `EV-${year}-${String(ticketSequence).padStart(6, '0')}`,
        sessionId: order.sessionId,
        orderId: order.id,
        status: 'valid'
      });
    }
    order.status = 'issued';
    callback(null, tickets);
  }, LATENCY_MS);
}

function releaseCapacity(sessionId, quantity, callback) {
  setTimeout(() => {
    findSession(sessionId, (error, result) => {
      if (error) {
        callback(error);
        return;
      }
      result.session.sold -= quantity;
      callback(null, result.session.capacity - result.session.sold);
    });
  }, LATENCY_MS);
}

// --- The purchase flow, flat ---

function buyTickets(userId, eventId, sessionId, quantity, onDone) {
  const context = { userId, eventId, sessionId, quantity, capacityReserved: false };

  findEvent(eventId, onEventFound);

  function onEventFound(error, event) {
    if (error) return fail('find-event', error);
    context.event = event;
    reserveTickets(sessionId, quantity, onReserved);
  }

  function onReserved(error, reservation) {
    if (error) return fail('reserve-capacity', error);
    context.capacityReserved = true;
    context.reservation = reservation;
    findSession(sessionId, onSessionFound);
  }

  function onSessionFound(error, result) {
    if (error) return compensateAndFail('find-session', error);
    context.session = result.session;
    createOrder(userId, result.session, quantity, onOrderCreated);
  }

  function onOrderCreated(error, order) {
    if (error) return compensateAndFail('create-order', error);
    context.order = order;
    chargeOrder(order, onCharged);
  }

  function onCharged(error, paidOrder) {
    if (error) return compensateAndFail('charge', error);
    context.order = paidOrder;
    issueTickets(paidOrder, onIssued);
  }

  function onIssued(error, tickets) {
    if (error) return compensateAndFail('issue', error);
    context.tickets = tickets;
    onDone(null, context);
  }

  // Single error point, without compensation.
  function fail(step, error) {
    error.step = step;
    onDone(error, context);
  }

  // Single error point WITH compensation of the capacity already reserved.
  function compensateAndFail(step, error) {
    if (!context.capacityReserved) return fail(step, error);

    releaseCapacity(sessionId, quantity, (releaseError, available) => {
      if (releaseError) {
        console.error(`CRITICAL: capacity not released in ${sessionId}: ${releaseError.message}`);
      } else {
        console.error(`[compensation] capacity released in ${sessionId}, ${available} available`);
      }
      fail(step, error);
    });
  }
}

// --- Testing both paths ---

function show(error, context) {
  if (error) {
    console.error(`[${error.code || 'ERROR'}] failure at "${error.step}": ${error.message}`);
    return;
  }
  console.log('');
  console.log(`Order ${context.order.id} - ${context.order.status}`);
  console.log(`  Event  : ${context.event.title}`);
  console.log(`  Session: ${context.session.id}`);
  console.log(`  Amount : ${(context.order.totalCents / 100).toFixed(2)} EUR`);
  for (const ticket of context.tickets) {
    console.log(`  ${ticket.code}  ${ticket.status}`);
  }
}

// Happy path: 3 x 18.00 = 54.00 EUR
buyTickets('att-001', 'evt-002', 'ses-002-2', 3, show);

// Declined path: 5 x 42.00 = 210.00 EUR, exceeds the limit
buyTickets('att-002', 'evt-003', 'ses-003-2', 5, show);

Output:

Order ord-001 - issued
  Event  : Noche de Monologos
  Session: ses-002-2
  Amount : 54.00 EUR
  EV-2026-000001  valid
  EV-2026-000002  valid
  EV-2026-000003  valid
[compensation] capacity released in ses-003-2, 180 available
[PAYMENT_DECLINED] failure at "charge": Payment declined: 210.00 EUR exceeds the limit

What matters about this solution is not that it works, but how much structural work it took to make it work:

  • A context object to drag the state around, because the nested closures are gone.
  • A capacityReserved flag to know whether there is anything to compensate.
  • Two error exit points (fail and compensateAndFail) instead of one.
  • One more nested callback inside the compensation itself.

All of this is manual bookkeeping the language does not help you with. A try/finally would do the compensation's job in three lines… if try/catch worked with asynchronous code. And that is, precisely, the first thing promises give back.

Conclusion

You have learned the pattern that holds up Node's classic asynchrony. A callback is a function you hand over so it can be called later, and Node fixed a canonical shape for it: the error-first callback (error, result), with the error always in first place — so that it is impossible to ignore — and null when everything went well. You have written Escena Viva's asynchronous data layer with that signature: findEvent, findSession and reserveTickets, with errors that carry their own code (INSUFFICIENT_CAPACITY, SESSION_NOT_FOUND) which in Module 6 will translate directly into HTTP statuses.

You have internalized the two rules that admit no exception: call the callback exactly once — hence the mandatory return after every call — and always call it asynchronously, even on the fast validation path, using process.nextTick so as not to "release Zalgo" with a function that is sometimes synchronous and sometimes not. And you have seen, demonstrated step by step over the call stack, why try/catch does not catch errors thrown inside an asynchronous callback: the try block was already over when the event loop runs your function on a new stack.

Then you built the pyramid of doom with Escena Viva's real purchase flow — find the event, check capacity, create the order, charge, issue tickets — and confirmed that the problem is not aesthetic: it is error handling duplicated five times, compensation nested inside the charge error, variables trapped in each closure and the impossibility of parallelizing what is independent. The classic mitigation techniques — named functions, early return, modularizing and an inSeries helper — made it livable, but at the price of carrying the state by hand in a context object and writing machinery the language ought to provide.

And even so, callbacks are not history: you need them for EventEmitter, for Module 3's streams, for Express middleware and for the many Node APIs that will never get a promise-based version.

What is missing is a way for asynchrony to look like normal code again: for a result to be returned, for an error to be thrown and caught with try/catch, for independent steps to be launched at once with a single instruction and for compensation to be written in a finally. That is exactly what the next lesson offers, Promises and async/await, where you will convert the functions you have just written with util.promisify and rewrite this very purchase pyramid until it is flat and readable from top to bottom.

Node.js Course: From Beginner to Advanced

Module 1: Introduction to Node.js

Module 2: Core Concepts

Module 3: File System and I/O

Module 4: HTTP and Web Servers

Module 5: NPM and Package Management

Module 6: The Express.js Framework

Module 7: Databases and ORMs

Module 8: Authentication and Authorization

Module 9: Testing and Debugging

Module 10: Advanced Topics

Module 11: Deployment and DevOps

Module 12: Real-World Projects

© Copyright 2026. All rights reserved