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
- What a callback is
- Synchronous versus asynchronous: the same problem, two shapes
- Node's canonical pattern: the error-first callback
- Writing your own asynchronous functions
- The golden rule: exactly once, and always asynchronously
- Why
try/catchdoes not catch asynchronous errors - The pyramid of doom in Escena Viva
- Classic mitigation techniques
- Advantages and drawbacks of callbacks
- Why callbacks still matter
- 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 |
- 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');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.
- 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.
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:
- The callback is always the last parameter of the function.
- The callback's first argument is always the error, or
nullif everything went well. - The error is an
Errorobject, not a string. AnErrorcarriesmessage,stackand — in Node — often acode('ENOENT','EACCES') that lets you decide without parsing the text. - 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
returnafter handling the error is not optional. Without it, execution carries on into the happy-path code withresultbeingundefined, and the real failure ends up buried under aTypeError: 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.
- 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.codealongsideerror.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.findSessionreturns{ event, session }. Returning the information the caller is going to need anyway avoids a second lookup.
- 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
});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}"`);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:
| 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.readFilewith 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.
- Why
try/catch does not catch asynchronous errors
try/catch does not catch asynchronous errorsThis 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.
- 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.
- 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.
- 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.
- 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:
- A
listSessionsAtRisk(thresholdPercent, callback)function that usesfindEventto load the three events one at a time, in series (the ids areevt-001,evt-002andevt-003) and returns through the callback an array of{ eventId, title, sessionId, dateTime, percentage }objects. - Correct error handling: if any event fails, the callback must receive the error and must not be called again.
- Output to
stdoutwithconsole.tableandprocess.exitCode = 1if there is any session at risk. - 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 codePAYMENT_DECLINEDiftotalCents > 20000; otherwise returns the order withstatus: 'paid'.issueTickets(order, callback)→ returns an array ofquantitytickets{ code: 'EV-2026-000001', sessionId, status: 'valid' }and leaves the order atstatus: 'issued'.releaseCapacity(sessionId, quantity, callback)→ returns the number of available seats after releasing.
Requirements:
- A flat structure, with named functions and a
contextobject (technique 8.1). - A single error-handling point.
- Correct compensation: if the charge fails, the capacity is released before reporting.
- Test both paths:
ses-002-2with 3 tickets (54.00 EUR, must succeed) andses-003-2with 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.
});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:
nextEventis 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 whatasync.paralleldid), but it is manual, error-prone machinery. In the next lesson,Promise.allsolves 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
contextobject to drag the state around, because the nested closures are gone. - A
capacityReservedflag to know whether there is anything to compensate. - Two error exit points (
failandcompensateAndFail) 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
- What Is Node.js?
- Installing and Setting Up the Environment
- Your First Node.js Program
- The Node.js REPL
- Modern JavaScript for Node.js
- The Course Project: the Escena Viva Platform
Module 2: Core Concepts
- Node.js Architecture
- The Event Loop
- Callbacks and Asynchronous Programming
- Promises and async/await
- Events and EventEmitter
- CommonJS Modules and require()
- ES Modules and Interoperability
Module 3: File System and I/O
- Reading and Writing Files
- The fs Module in Depth
- Cross-Platform Paths with the path Module
- Working with Streams
- Transform Streams and pipeline
- Buffers and Binary Data
Module 4: HTTP and Web Servers
- Creating a Simple HTTP Server
- Handling Requests and Responses
- Manual Routing
- Serving Static Files
- Receiving Data: Request Bodies and JSON
- Consuming External APIs from Node.js
Module 5: NPM and Package Management
- Introduction to NPM and package.json
- Installing and Using Packages
- Semantic Versioning and package-lock
- npm Scripts and Project Automation
- Creating and Publishing Packages
- Dependency Security and Maintenance
Module 6: The Express.js Framework
- Introduction to Express.js
- Setting Up an Express Application
- Routing in Express
- Middleware
- Essential Third-Party Middleware
- Input Data Validation
- Error Handling
Module 7: Databases and ORMs
- Introduction to Databases
- Using MongoDB with Mongoose
- CRUD Operations
- Relationships, Population and Advanced Queries
- Using SQL Databases with Sequelize
- Migrations, Transactions and Seed Data
Module 8: Authentication and Authorization
- Introduction to Authentication
- User Registration and Password Hashing
- Sessions and Cookies with Passport.js
- Authentication with JWT
- Role-Based Access Control
- API Security Best Practices
Module 9: Testing and Debugging
- Introduction to Testing
- Unit Testing with Mocha and Chai
- Test Doubles with Sinon
- Integration Testing
- Coverage and Test Automation
- Debugging Node.js Applications
Module 10: Advanced Topics
- The Cluster Module
- Worker Threads
- Caching and Job Queues with Redis
- Performance Optimization
- Building RESTful APIs
- GraphQL with Node.js
Module 11: Deployment and DevOps
- Configuration and Environment Variables
- Logging and Monitoring in Production
- Using PM2 for Process Management
- Packaging with Docker
- Deploying to Heroku and Other PaaS
- Continuous Integration and Deployment
