The data/backlog.js module you closed the previous lesson with holds a lie inside: it returns the six tasks instantly, because they are hand-written in the file itself. In the real application that data will live on a server, and asking for it will take anywhere between fifty milliseconds and several seconds, depending on the network. The question that opens this block of the course is what the program does in the meantime. And the obvious answer —"wait"— turns out to be catastrophic in a language that runs one thing at a time: while it waits, it cannot do anything else at all, so the page freezes. In this lesson you will understand why JavaScript needs asynchrony, you will learn the oldest tools for handling it —setTimeout, setInterval and the callback pattern—, you will simulate loading the backlog with latency, and you will arrive under your own steam at the famous callback hell, the pyramid of code that motivated the invention of promises.
Contents
- A single thread: what it means and why it matters
- Synchronous versus asynchronous, on a timeline
setTimeout: scheduling for latersetInterval,clearTimeoutandclearInterval- Why
setTimeout(f, 0)is not immediate - The callback pattern
- The error-first convention
- Simulating the backlog load
- Chaining three operations: the pyramid
- The four problems with callbacks
try/catchdoes not catch asynchronous errors- Partial mitigations and why they are not enough
- Common Mistakes and Tips
- Exercises
- Conclusion
- A single thread: what it means and why it matters
JavaScript is a single-threaded language: there is one call stack —the one you studied in 03-05— and exactly one function runs on it at any given instant. There are never two pieces of your code running at once.
That has an enormous advantage, best appreciated if you have suffered through other languages: you never have to worry that another thread will change a variable halfway through a function. The task array cannot be modified while your reduce walks it. The entire class of bugs called "race conditions over shared memory" simply does not exist.
And it has an equally large drawback: if one function takes a long time, everything else waits. In the browser, that "everything else" includes repainting the screen, responding to clicks and animating anything at all.
'use strict';
/** Blocks the thread for the given number of milliseconds. Do NOT do this in production. */
function blockingWait(ms) {
const end = Date.now() + ms;
while (Date.now() < end) {
// spinning on the spot, burning the processor
}
}
console.log('Loading the backlog…');
blockingWait(3000); // 3 seconds of total paralysis
console.log('Backlog loaded');During those three seconds the tab is dead: buttons do not respond, text cannot be selected and, if the browser decides enough is enough, the "page is not responding" warning appears. A user gives up long before that.
flowchart TD
A["Call stack<br/>(only one)"] --> B["blockingWait(3000)<br/>occupies the stack for 3 s"]
B --> C["⛔ Nothing else can run:<br/>no repainting, no clicks, no timers"]
The solution is not to add threads, but to not wait: ask for the data, say what to do when it arrives, and hand control back immediately so the program stays alive. That is asynchronous programming.
- Synchronous versus asynchronous, on a timeline
Compare the two styles on the same problem: loading the backlog and printing the summary.
// ── SYNCHRONOUS (hypothetical): the function returns the result ───
const backlog = loadBacklogBlocking(); // ⏳ the program stops here
console.log(backlog.length); // 6
console.log('Done');// ── ASYNCHRONOUS: the function returns nothing; it tells you when it finishes ──
loadBacklog((tasks) => { // ← what to do WHEN it arrives
console.log(tasks.length); // 6
});
console.log('Done'); // ← runs BEFORE the 6That change of order is the first thing that throws people. The output of the second block is:
Because loadBacklog does not wait: it registers the function you pass it, hands control back immediately and the program carries on. When the data is available —ten, a hundred or a thousand milliseconds later— the registered function runs.
flowchart TD
subgraph S["Synchronous · 3.1 s of blocking"]
S1["loadBacklog<br/>0 → 3000 ms<br/>⛔ thread blocked"] --> S2["console.log(6)<br/>3000 ms"] --> S3["console.log('Done')<br/>3001 ms"]
end
subgraph A["Asynchronous · 1 ms of thread time"]
A1["loadBacklog(cb)<br/>0 ms · registers and returns"] --> A2["console.log('Done')<br/>1 ms"]
A2 --> A3["… the thread is FREE<br/>1 → 3000 ms"]
A3 --> A4["cb(tasks) · console.log(6)<br/>3000 ms"]
end
In the asynchronous version the data arrives at the same instant —the network takes as long as it takes— but during those three seconds the thread is free: the interface responds, animations run, other data can be requested in parallel.
An important nuance that is often explained badly: asynchrony does not make anything faster. What it does is avoid wasting the thread while waiting for something that does not depend on it. Somebody else does the waiting: the browser's networking system, the operating system's timer, the disk. Your code merely signs up to be told.
setTimeout: scheduling for later
setTimeout: scheduling for laterThe simplest form of asynchronous code is setTimeout, which registers a function to run after a certain time.
'use strict';
console.log('1 · before');
setTimeout(() => {
console.log('3 · inside the timeout');
}, 1000);
console.log('2 · after');
// Output:
// 1 · before
// 2 · after
// 3 · inside the timeout ← one second laterIts full signature:
fn: what to run. It is a callback: a function you write but somebody else calls.milliseconds: the minimum delay. If omitted, it is 0.arg1, arg2…: arguments that will be passed to the callback.- Returns an identifier, which is used to cancel it.
setTimeout((person, hours) => {
console.log(`Reminder: ${person} has ${hours} h open`);
}, 500, 'Iván', 25);
// Reminder: Iván has 25 h openAn alternative you will see a lot and should avoid: passing the arguments through an arrow that captures them.
setTimeout(() => notify('Iván', 25), 500); // ✓ perfectly legitimate and more readable
setTimeout(notify('Iván', 25), 500); // ✗ ERROR: calls notify NOW and passes its return value
setTimeout('notify("Iván", 25)', 500); // ✗ never: it is eval in disguiseThe second line is the classic mistake: the parentheses call the function, so setTimeout receives the returned value (usually undefined) instead of a function. It is the same slip from 03-02 when passing functions as arguments.
setInterval, clearTimeout and clearInterval
setInterval, clearTimeout and clearIntervalsetInterval repeats the callback every N milliseconds, indefinitely, until it is cancelled.
'use strict';
let round = 0;
const intervalId = setInterval(() => {
round += 1;
console.log(`Checking for overdue tasks… round ${round}`);
if (round === 3) {
clearInterval(intervalId); // ← essential: without this, it goes on forever
console.log('Check stopped');
}
}, 1000);And clearTimeout cancels a setTimeout that has not fired yet:
const warningId = setTimeout(() => {
console.log('⚠ The carpentry workshop quote has been overdue for 3 days');
}, 5000);
// If the task is completed sooner, the warning no longer makes sense
board.changeStatus(6, 'in-progress');
clearTimeout(warningId); // the callback will never run| Function | What it does | Cancel with |
|---|---|---|
setTimeout(f, ms) |
Runs f once, after at least ms |
clearTimeout(id) |
setInterval(f, ms) |
Runs f every ms, endlessly |
clearInterval(id) |
A warning about setInterval that is expensive to discover in production: it does not wait for the callback to finish. If you schedule an interval of 100 ms and the callback takes 150 ms, the executions overlap and pile up. For work of variable duration —querying a server, for example— a setTimeout that reschedules itself once it is done is safer:
/** Safe repetition: each cycle starts once the previous one has finished. */
function checkPeriodically(interval) {
setTimeout(function cycle() {
checkOverdue(); // however long it takes…
setTimeout(cycle, interval); // …the next cycle is scheduled afterwards
}, interval);
}And an operational rule that will save you memory leaks (Module 9): always keep the identifier and cancel when the work stops making sense. A forgotten interval keeps running, draining battery and keeping alive by reference every variable its callback captures.
- Why
setTimeout(f, 0) is not immediate
setTimeout(f, 0) is not immediateThis is one of the most misunderstood lines in the language:
'use strict';
console.log('A');
setTimeout(() => console.log('B'), 0);
console.log('C');
// Output: A, C, BWith a delay of zero milliseconds, B still comes out last. The reason is that the second argument is not "when it will run", but "the minimum time that must pass before it is a candidate to run". And to be a candidate something else is needed: the call stack has to be empty.
The complete mechanism, in three steps:
setTimeouthands the callback to the environment (the browser or Node), which starts a timer. This does not occupy the thread.- Once the time is up, the environment places the callback in a queue of pending tasks.
- When the code currently running finishes completely and the stack is empty, the first one in the queue is taken and run.
That is why a setTimeout(f, 0) behind a heavy loop does not run until the loop is over:
setTimeout(() => console.log('Should come out right away'), 0);
let sum = 0;
for (let i = 0; i < 500_000_000; i++) sum += i; // several seconds
console.log('Loop finished');
// Loop finished
// Should come out right away ← it waited for the stack to emptyThere is also a historical detail: by specification, timers nested beyond a certain depth are raised to a minimum of 4 ms in browsers. So setTimeout(f, 0) really means "as soon as possible, and at the earliest in a few milliseconds, when the thread is free".
That use —"run this after what I am doing now"— is perfectly legitimate and is called yielding the thread. The whole queueing mechanism you have just glimpsed has a name of its own, a precise algorithm and several more subtleties, and it is the full subject of The Event Loop and the Microtask Queue. For now the rule is enough: all the synchronous code finishes first; then the pending callbacks.
- The callback pattern
A callback is a function you pass to another one so it can call it later. You have known the idea since 03-06: it is exactly what you did with map, filter and sort. The difference is when it gets called.
| Type | Example | When the callback runs |
|---|---|---|
| Synchronous | [1,2,3].map((n) => n * 2) |
Immediately, inside the call. map does not return until it is done |
| Asynchronous | setTimeout(() => …, 1000) |
Later, when the stack is free. The function returns instantly |
An asynchronous callback is recognized by an unmistakable sign: the function that receives it does not return the result. Compare:
// Synchronous: the result comes out through return
function findTask(backlog, id) {
return backlog.find((t) => t.id === id) ?? null;
}
const task = findTask(backlog, 6);
// Asynchronous: the result comes out through the callback
function findTaskOnServer(id, callback) {
setTimeout(() => {
callback(backlog.find((t) => t.id === id) ?? null);
}, 300);
// no return: the function ends here, with no result
}
findTaskOnServer(6, (task) => console.log(task.title));From that comes the rule that governs this whole lesson:
A value produced asynchronously cannot be returned with
return. It can only be handed to somebody who is waiting to receive it.
And from that also comes the most frequent beginner's mistake:
function findTaskWrong(id) {
let result = null;
setTimeout(() => { result = backlog.find((t) => t.id === id); }, 300);
return result; // ✗ ALWAYS null: the return happens 300 ms earlier
}
console.log(findTaskWrong(6)); // nullThere is no way of fixing that function while keeping its signature. The value does not exist yet when the return runs. What has to change is the design, not the code.
- The error-first convention
If the result travels through the callback, errors have to travel that way too. Node.js established a convention that was adopted everywhere: the callback receives the error as its first argument and the result as its second.
- If everything went well:
callback(null, data). - If something failed:
callback(new Error('…')), with no second argument.
'use strict';
import { DataError } from '../model/errors.js';
/** Looks up a task on the "server" (simulated). Error-first convention. */
function findTaskOnServer(id, callback) {
setTimeout(() => {
if (typeof id !== 'number') {
callback(new DataError(`The id must be a number, received: ${typeof id}`));
return; // ← essential: without it, execution continues
}
const task = backlogData.find((t) => t.id === id);
if (task === undefined) {
callback(new DataError(`Task ${id} does not exist`));
return;
}
callback(null, task); // success: first argument null
}, 300);
}
findTaskOnServer(6, (error, task) => {
if (error) { // 1 · ALWAYS check the error, and first
console.error(`✗ ${error.message}`);
return;
}
console.log(`✓ ${task.title}`); // 2 · by here we know there is a result
});
// ✓ Carpentry workshop quote
findTaskOnServer(99, (error, task) => {
if (error) { console.error(`✗ ${error.message}`); return; }
console.log(`✓ ${task.title}`);
});
// ✗ Task 99 does not existThree details to observe religiously:
- Check the error first. If you skip that
if,taskwill beundefinedand the error will turn into a confusingTypeErrorthree lines further down. returnafter calling the callback with an error. It is the same guardreturnyou learned in 02-01: without it, the function carries on and may end up calling the callback twice, which breaks whoever consumes it.- Call the callback exactly once. Not zero times (the consumer waits forever, with no error at all) and not twice.
- Simulating the backlog load
With all of the above, we can now replace the instant createBacklog() from 05-04 with a version that behaves like the real world: with latency and with the possibility of failure.
// js/data/simulatedBacklog.js
import { Task } from '../model/task.js';
import { DataError } from '../model/errors.js';
import { backlogData } from './backlog.js';
/**
* Simulates loading the backlog from a server.
* In Module 7 this will be a real call with fetch (07-02);
* here the latency is faked with setTimeout.
*
* @param {Function} callback (error, tasks) => void
* @param {Object} [options]
* @param {number} [options.latency=400] milliseconds of simulated wait
* @param {boolean} [options.fail=false] forces an error, to test the unhappy path
*/
export function readSimulatedBacklog(callback, options = {}) {
const { latency = 400, fail = false } = options;
setTimeout(() => {
if (fail) {
callback(new DataError('The Taller Nómada server is not responding (503).'));
return;
}
try {
const tasks = backlogData.map((data) => new Task(data));
callback(null, tasks);
} catch (error) {
callback(new DataError('The backlog received is not valid.', error));
}
}, latency);
}And its use from app.js:
import { readSimulatedBacklog } from './data/simulatedBacklog.js';
import { Board } from './model/board.js';
import { TODAY } from './util/dates.js';
console.log('⏳ Loading the backlog…');
readSimulatedBacklog((error, tasks) => {
if (error) {
console.error(`✗ ${error.message}`);
return;
}
const board = new Board('Taller Nómada', tasks);
console.log('✓ Backlog loaded');
console.log(board.summary(TODAY));
// { total: 6, open: 5, totalHours: 48, openHours: 45, overdue: 1, effort: 124 }
});
console.log('The application keeps responding while it loads');
// Output:
// ⏳ Loading the backlog…
// The application keeps responding while it loads
// ✓ Backlog loaded
// { total: 6, open: 5, totalHours: 48, openHours: 45, overdue: 1, effort: 124 }Notice the order: the "keeps responding" message comes out before the data. That is the proof that the thread was left free. And note a design detail too: the try/catch is inside the setTimeout, wrapping the construction of the tasks, and it turns any ValidationError from the constructor into a callback call with an error. Why it has to be in there is what you will see in section 11.
Progress reminder:
readSimulatedBacklogfakes the network withsetTimeout. The real API —fetch, HTTP status codes, headers and everything else— arrives in 07-02, and the robust way of handling its failures and timeouts, in 07-03. What matters here is the shape of asynchronous code, not where the data comes from.
- Chaining three operations: the pyramid
So far, callbacks look reasonable. The problem shows up when one asynchronous operation depends on the result of another. Marta asks for a weekly report that requires three steps, and each one needs what the previous one produced:
- Load the backlog.
- With the assignees that appear, load their contracted hours.
- With both, generate the report and save it.
We add the two missing simulated modules:
// js/data/simulatedTeam.js
import { DataError } from '../model/errors.js';
const CONTRACTS = { 'Iván': 30, 'Marta': 20, 'Lucía': 35 }; // contracted weekly hours
export function readSimulatedTeamHours(names, callback, latency = 300) {
setTimeout(() => {
const hours = {};
for (const name of names) {
if (!Object.hasOwn(CONTRACTS, name)) {
callback(new DataError(`${name} is not on the Taller Nómada team.`));
return;
}
hours[name] = CONTRACTS[name];
}
callback(null, hours);
}, latency);
}
export function saveSimulatedReport(report, callback, latency = 200) {
setTimeout(() => {
if (report.lines.length === 0) {
callback(new DataError('An empty report is not saved.'));
return;
}
callback(null, { saved: true, id: `rep-${Date.now()}`, lines: report.lines.length });
}, latency);
}And now the report, written with callbacks:
import { readSimulatedBacklog } from './data/simulatedBacklog.js';
import { readSimulatedTeamHours, saveSimulatedReport } from './data/simulatedTeam.js';
import { Board } from './model/board.js';
import { TODAY } from './util/dates.js';
console.log('⏳ Generating the weekly report…');
readSimulatedBacklog((backlogError, tasks) => {
if (backlogError) {
console.error(`✗ Failed to load the backlog: ${backlogError.message}`);
return;
}
const board = new Board('Taller Nómada', tasks);
const workload = board.hoursByAssignee();
const names = Object.keys(workload);
readSimulatedTeamHours(names, (teamError, contracted) => {
if (teamError) {
console.error(`✗ Failed to load the team: ${teamError.message}`);
return;
}
const lines = names.map((name) => ({
name,
assigned: workload[name],
contracted: contracted[name],
overloaded: workload[name] > contracted[name]
}));
saveSimulatedReport({ date: TODAY, lines }, (saveError, receipt) => {
if (saveError) {
console.error(`✗ Failed to save: ${saveError.message}`);
return;
}
console.log(`✓ Report ${receipt.id} saved with ${receipt.lines} lines`);
for (const l of lines) {
const badge = l.overloaded ? '⚠' : '·';
console.log(` ${badge} ${l.name.padEnd(8)} ${l.assigned} h assigned / ${l.contracted} h contracted`);
}
});
});
});The output is correct:
⏳ Generating the weekly report… ✓ Report rep-1758... saved with 3 lines · Iván 25 h assigned / 30 h contracted · Marta 6 h assigned / 20 h contracted · Lucía 14 h assigned / 35 h contracted
But look at the shape of the code. Each operation nests the next one a level deeper, and the final closing is that staircase of }); that has earned itself a nickname:
flowchart TD
A["readSimulatedBacklog((e, tasks) => {"] --> B[" readSimulatedTeamHours((e, hours) => {"]
B --> C[" saveSimulatedReport((e, receipt) => {"]
C --> D[" console.log(…)"]
D --> E[" });"]
E --> F[" });"]
F --> G["});"]
This is called callback hell or the pyramid of doom. With three steps it is still readable; with six —load, validate, enrich, calculate, save, notify— it is illegible. And these are only three sequential steps: if on top of that you had to do two things in parallel and wait for both, you would have to invent a manual counter.
- The four problems with callbacks
The indentation is what you see, but it is not the worst of it. There are four real problems.
Problem 1: repeated error handling. Count the if (error) { console.error(...); return; } blocks in the previous example: three, one per level, practically identical. There is no way of writing "if anything fails at any point in this sequence, do this". Each level defends itself alone, and it only takes forgetting one if for a failure to go unnoticed and blow up later with a message that has nothing to do with it.
Problem 2: reading order is not execution order. The code reads top to bottom but runs in a dance of jumps. What happens after saveSimulatedReport is inside it, and what happens after everything is at the deepest level. Our brains read sequences; this is a tree.
Problem 3: inversion of control. When you write saveSimulatedReport(report, myCallback), you are handing your function to a third party. From then on, you control nothing:
| What whoever receives your callback can get wrong | Consequence |
|---|---|
| Never calling it | Your program hangs, with no error and no clue |
| Calling it twice | The report is saved twice, the counter is doubled |
| Calling it too soon (synchronously) | Execution order that is unpredictable case by case |
| Calling it with the arguments the wrong way round | You treat an error as if it were the result |
| Swallowing an exception from your callback | Failures vanish silently |
With a function of your own it does not happen; with a third-party library, all of that happens for real. And you have no tool to protect yourself, other than reading their code.
Problem 4: composing is impossible. With ordinary functions, combining is trivial: pipe(filter, sort, summarize), as you did in 03-06. With callbacks there is nothing equivalent. Operations as common as "do these three things at once and tell me when they have all finished" or "retry this three times if it fails" require hand-writing counters, flags and checks:
// "Run two loads in parallel and continue when both are done", by hand
let pending = 2;
let resultA = null;
let resultB = null;
let alreadyFailed = false;
function checkIfDone() {
pending -= 1;
if (pending === 0 && !alreadyFailed) carryOn(resultA, resultB);
}
loadA((error, data) => {
if (error) { alreadyFailed = true; return handle(error); }
resultA = data;
checkIfDone();
});
loadB((error, data) => {
if (error) { alreadyFailed = true; return handle(error); }
resultB = data;
checkIfDone();
});Four state variables and a helper function to express an idea that is half a line long. In the next lesson this will be Promise.all([loadA(), loadB()]).
try/catch does not catch asynchronous errors
try/catch does not catch asynchronous errorsIn 02-05 we noted a warning you can now fully understand: try/catch does not catch errors that happen inside an asynchronous callback.
'use strict';
try {
setTimeout(() => {
throw new Error('Failure inside the timer');
}, 100);
console.log('The try finished without problems');
} catch (error) {
console.error('Caught:', error.message); // ← NEVER runs
}
// Output:
// The try finished without problems
// …100 ms later: Uncaught Error: Failure inside the timerThe reason is exactly the one from section 5, and now it fits with what you know about the call stack from 03-05. try/catch protects a region of the stack: it catches whatever is thrown while those lines are running. When the callback runs, a hundred milliseconds later, the stack that contained the try no longer exists; the callback starts on a fresh, empty stack, with no try around it.
flowchart TD
subgraph T1["t = 0 ms · stack with the try"]
A["try { … }"] --> B["setTimeout registers the callback"]
B --> C["the try ends · the stack empties"]
end
subgraph T2["t = 100 ms · fresh stack"]
D["callback()"] --> E["throw Error"]
E --> F["⚠ nobody catches it:<br/>there is no try here"]
end
C -.->|"time passes"| D
The only way of catching it with callbacks is putting the try/catch inside the callback, which is exactly what we did in readSimulatedBacklog:
setTimeout(() => {
try {
const tasks = backlogData.map((data) => new Task(data));
callback(null, tasks);
} catch (error) { // ✓ the try is on the same stack as the throw
callback(new DataError('The backlog received is not valid.', error));
}
}, latency);And here is the most important design consequence of the whole lesson: with callbacks, errors do not propagate by themselves. In the synchronous code of 02-05, a throw at the bottom of ten calls climbed up the stack to the first try/catch, without the intermediate functions doing anything at all. With callbacks, every level has to catch its error and hand it up to the callback above by hand. The language's automatic propagation mechanism, one of its best features, stops working the moment you cross an asynchronous boundary.
- Partial mitigations and why they are not enough
There are techniques for easing the pyramid. They are worth knowing because old code uses them, and because understanding their limits explains why a new mechanism was needed.
Mitigation 1: naming the functions and flattening. Instead of nesting anonymous arrows, you declare named functions and pass them by reference.
'use strict';
let currentBoard = null;
let currentWorkload = null;
function onBacklogLoaded(error, tasks) {
if (error) return abort('load the backlog', error);
currentBoard = new Board('Taller Nómada', tasks);
currentWorkload = currentBoard.hoursByAssignee();
readSimulatedTeamHours(Object.keys(currentWorkload), onTeamLoaded);
}
function onTeamLoaded(error, contracted) {
if (error) return abort('load the team', error);
const lines = Object.keys(currentWorkload).map((name) => ({
name, assigned: currentWorkload[name], contracted: contracted[name]
}));
saveSimulatedReport({ date: TODAY, lines }, onReportSaved);
}
function onReportSaved(error, receipt) {
if (error) return abort('save the report', error);
console.log(`✓ Report ${receipt.id} saved`);
}
function abort(phase, error) {
console.error(`✗ Failed to ${phase}: ${error.message}`);
}
readSimulatedBacklog(onBacklogLoaded);It is better: the indentation is gone, every function has a name and the error traces from 03-05 are readable. But look at the price:
- Shared variables have appeared (
currentBoard,currentWorkload) to pass data between steps. It is global state reintroduced through the back door, with all the problems you learned to avoid in 03-04. - The sequence is no longer readable anywhere. To know what happens after the backlog loads you have to find the last line of
onBacklogLoaded, go toonTeamLoaded, and so on. The order is scattered across the file. - Error handling is still repeated in every function, even though it now delegates to
abort.
Mitigation 2: modularizing. Putting each step in its own module (05-04) improves organization, but changes none of the above: the problems are about the shape of the control flow, not about where the code lives.
Mitigation 3: flow-control libraries. Around 2012, libraries like async became popular, with functions such as waterfall, series and parallel:
// Flow-control library style (illustrative)
series([loadBacklog, loadTeam, saveReport], (error, results) => { … });They worked, but they were an external convention: you had to learn them, they were not part of the language, and the inversion of control of problem 3 was still intact, because you were still handing your functions to a third party.
No mitigation touches the two underlying problems:
| Problem | Does naming/modularizing fix it? |
|---|---|
| Pyramid indentation | Yes |
| Errors repeated at every level | No |
try/catch useless over asynchronous code |
No |
| Inversion of control | No |
| Composing (parallel, retries, timeouts) | No |
What is needed is for an asynchronous operation to return something: an object representing "the result that will arrive", which can be stored in a variable, passed to a function, chained and combined, and which propagates errors automatically the way the synchronous stack did. That object exists, it is called a promise, and it is the subject of the next lesson.
Common Mistakes and Tips
- Trying to
returnfrom an asynchronous callback. The value does not exist when thereturnruns. If you find yourself writinglet result; setTimeout(() => result = …); return result;, stop: the design has to change, not the code. - Calling the function instead of passing it.
setTimeout(notify(), 100)runsnotifyimmediately. You passnotifyor() => notify(...). - Forgetting the
returnafter calling the callback with an error. The function carries on and ends up calling the callback a second time with incomplete data. It is a devilish bug to debug. - Not checking the error, or checking it after using the result. The first line of the callback must be
if (error) { …; return; }. - Wrapping an asynchronous call in
try/catchexpecting to catch something. It does not work: thecatchbelongs to a stack that no longer exists. Thetrygoes inside the callback. - Forgetting
clearInterval. An orphaned interval runs forever, drains battery and keeps alive by reference every variable it captures: it is a textbook memory leak (Module 9). - Trusting the exact value of the delay.
setTimeout(f, 100)means "not before 100 ms", never "exactly at 100 ms". To measure real time, use marks withDate.now()rather than counting timers. - Loops with timers inside.
for (let i = 0; i < 3; i++) setTimeout(() => console.log(i), 0)prints0 1 2withletbut3 3 3withvar: it is exactly the block-scoping case from 03-04, now with asynchronous consequences. - Tip: name callbacks after what happens, not after what they do:
onBacklogLoaded,onSaveFailed. When they turn up in an error trace at three in the morning, you will be grateful.
Exercises
Exercise 1 — Predict the output. Without running it, write the exact order in which the six lines appear and justify each one.
console.log('1');
setTimeout(() => console.log('2'), 0);
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(`3.${i}`), 100 - i * 50);
}
console.log('4');Exercise 2 — countTasksByAssigneeSimulated. Write a function following the error-first convention that takes (assignee, callback) and, after a simulated 250 ms, delivers { assignee, open, hours } with the data from the canonical backlog. It must fail with DataError if the assignee does not exist or is not a string. Try it with 'Iván' (it should give 3 open tasks and 25 h), with 'Nobody' and with 42.
Exercise 3 — Retries with callbacks. Write withRetries(operation, attempts, callback) that runs an error-first asynchronous operation and, if it fails, retries it up to attempts times before giving up, waiting 200 ms between attempts. Try it with an operation that fails the first two times and works on the third. Then answer: how many lines did it take you, and which part is retry logic and which is callback plumbing?
Solutions
Exercise 1
1 · synchronous 4 · synchronous: all top-level code runs first 3.2 · delay 0 ms (100 - 2*50) 2 · delay 0 ms, but it was REGISTERED before 3.2… see note 3.1 · delay 50 ms 3.0 · delay 100 ms
The interesting point is the order between 2 and 3.2. Both have a delay of 0, and when two timers come due at the same time they run in the order they were registered, so in practice you will see 2 before 3.2:
Two lessons. The first: all the synchronous code goes first, without exception; 4 comes out before any timer even though the timers have zero delay. The second: callbacks do not come out in the order they were written, but by due time; the loop registers the decreasing delays 100, 50 and 0, so the output runs the opposite way round to the loop. And notice that i holds the right value in each one thanks to let, which creates a new variable per iteration (03-04); with var you would have got three 3.3s.
Exercise 2
'use strict';
import { backlogData } from './data/backlog.js';
import { DataError } from './model/errors.js';
/**
* Counts the open tasks and hours of an assignee.
* @param {string} assignee
* @param {Function} callback (error, summary) => void
*/
export function countTasksByAssigneeSimulated(assignee, callback) {
setTimeout(() => {
if (typeof assignee !== 'string') {
callback(new DataError(`The assignee must be a string, received: ${typeof assignee}`));
return;
}
const theirs = backlogData.filter((t) => t.assignee === assignee);
if (theirs.length === 0) {
callback(new DataError(`${assignee} has no tasks in the backlog.`));
return;
}
const open = theirs.filter((t) => t.status !== 'done');
callback(null, {
assignee,
open: open.length,
hours: open.reduce((s, t) => s + t.estimatedHours, 0)
});
}, 250);
}
function show(error, summary) {
if (error) { console.error(`✗ ${error.message}`); return; }
console.log(`✓ ${summary.assignee}: ${summary.open} open, ${summary.hours} h`);
}
countTasksByAssigneeSimulated('Iván', show); // ✓ Iván: 3 open, 25 h
countTasksByAssigneeSimulated('Nobody', show); // ✗ Nobody has no tasks in the backlog.
countTasksByAssigneeSimulated(42, show); // ✗ The assignee must be a string, received: numberIván's 25 h are the canonical ones: 12 for the multipurpose room, 8 for the bookbinding guide and 5 for the carpentry workshop quote. Notice the three calls in a row: all three are registered immediately, without any of them waiting for the previous one, and their results arrive at around 250 ms more or less together. That is parallelism of asynchronous operations, for free, with no coordination at all. The problem —as you saw in section 10— appears when you need to know when they have all finished.
Exercise 3
'use strict';
/**
* Runs an error-first operation, retrying it if it fails.
* @param {Function} operation (callback) => void
* @param {number} attempts maximum number of attempts
* @param {Function} callback (error, result) => void
*/
function withRetries(operation, attempts, callback) {
let remaining = attempts;
function attempt() {
operation((error, result) => {
if (!error) {
callback(null, result);
return;
}
remaining -= 1;
if (remaining === 0) {
callback(new Error(`All ${attempts} attempts failed. Last error: ${error.message}`));
return;
}
console.log(` ↻ retrying… ${remaining} left`);
setTimeout(attempt, 200);
});
}
attempt();
}
// Test operation: fails the first two times
let times = 0;
function unstableServer(callback) {
times += 1;
const attemptNumber = times;
setTimeout(() => {
if (attemptNumber < 3) callback(new Error(`503 on attempt ${attemptNumber}`));
else callback(null, { backlog: 6, hours: 48 });
}, 100);
}
withRetries(unstableServer, 4, (error, data) => {
if (error) { console.error(`✗ ${error.message}`); return; }
console.log('✓ Received:', data);
});
// Output:
// ↻ retrying… 3 left
// ↻ retrying… 2 left
// ✓ Received: { backlog: 6, hours: 48 }Answering the question in the statement: it is about twenty lines, and only three of them express the retry idea (remaining -= 1, the exhaustion check and the setTimeout(attempt, 200)). Everything else is plumbing: the inner attempt function that exists only so it can repeat itself, the manual error check, the guard returns after every callback call and the closing of the nesting. And there is a hidden fragility: if operation called its callback twice —problem 3 from section 10—, withRetries would call its own twice, and nothing in this code prevents it.
Keep this exercise. When you rewrite it with promises and async/await in the next lesson, the retry logic will fit in a five-line for with an ordinary try/catch, and the double-call problem will disappear by construction.
Conclusion
You have understood the underlying problem and the first-generation tools for solving it. JavaScript has a single thread, which spares it a whole category of concurrency bugs but imposes an implacable rule: while one function is running, nothing else can be. Blocking for three seconds does not mean "taking three seconds", it means freezing the entire application for three seconds. The way out is not waiting better, but not waiting at all: ask for the data, declare what to do when it arrives, and hand back control immediately. That is why the output of an asynchronous program is disconcerting at first —'Done' appears before the data— and that is why that inversion of order is precisely the sign that the thread has been left free.
You have the basic pieces down. setTimeout to schedule a future execution, with its identifier and its clearTimeout; setInterval to repeat, with the warning that it does not wait for the callback to finish and that a forgotten interval is a memory leak; and the explanation of why setTimeout(f, 0) is not immediate: the delay is a minimum, and on top of that the callback has to wait for the call stack to empty. You can recognize an asynchronous callback by an unmistakable sign —the function that receives it does not return the result— and from there the rule that governs everything: a value produced asynchronously cannot be returned with return, only handed over. And you know Node's error-first convention, (error, result), with its three disciplines: check the error first, put a return after every callback call, and call it exactly once.
With that you have built readSimulatedBacklog, readSimulatedTeamHours and saveSimulatedReport, you have loaded the canonical backlog with faked latency —48 h, 45 open, 1 overdue, effort 124, with the application responding meanwhile— and you have chained the three steps of Marta's weekly report until you saw the pyramid with your own eyes. And you have diagnosed why it hurts, which is the important part: error handling is repeated at every level, reading order stops matching execution order, handing your function to a third party is an inversion of control with no guarantees —it may not call it, may call it twice or may swallow its exceptions—, and composing operations (two in parallel, a retry, a maximum time) demands inventing counters and flags by hand. Above all, you have confirmed the warning left pending in 02-05: try/catch does not catch asynchronous errors, because by the time the callback runs the stack that contained the try no longer exists. Automatic error propagation, one of the language's best features, stops working the moment you cross an asynchronous boundary.
The mitigations —naming the functions, flattening the pyramid, modularizing, using flow-control libraries— fix the indentation and little else, and on top of that they reintroduce shared variables for passing data between steps. The problem was not cosmetic. What is missing is for an asynchronous operation to return a value: an object representing the future result, which can be stored in a variable, passed to a function, chained without nesting, combined in parallel and, above all, which propagates errors by itself the way the synchronous stack did. That object has existed since ES2015, it is called a promise, and with the async/await syntax built on top of it, it lets you write asynchronous code that reads exactly like synchronous code —try/catch included. It is the subject of Promises and Async/Await, where you will write Marta's weekly report again and see how much code disappears.
JavaScript Course: From Beginner to Advanced
Module 1: Introduction to JavaScript
- What Is JavaScript?
- Setting Up Your Development Environment
- Your First JavaScript Program
- JavaScript Syntax and Basic Concepts
- Variables and Data Types
- Basic Operators
- Type Conversion and Comparisons
- The Course Project: Nómada Tasks
Module 2: Control Structures
- Conditional Statements
- Loops: for, while, do-while
- Switch Statements
- Flow Control: break, continue and Nested Loops
- Error Handling with try-catch
Module 3: Functions
- Defining and Calling Functions
- Function Expressions and Arrow Functions
- Parameters and Return Values
- Scope and Closures
- Hoisting and the Execution Context
- Higher-Order Functions
- Recursion
Module 4: Objects and Arrays
- Introduction to Objects
- Object Methods and the
thisKeyword - Arrays: Basics and Methods
- Iterating over Arrays
- Searching, Sorting and Aggregating Data: find, sort and reduce
- Array Destructuring
- Object Destructuring, Spread and Rest
- JSON and Copying Objects
Module 5: Advanced Objects and Functions
- Prototypes and Inheritance
- Classes and Object-Oriented Programming
- Encapsulation: Getters, Setters and Private Fields
- Modules: Import and Export
- Asynchronous JavaScript: Callbacks
- Promises and Async/Await
- The Event Loop and the Microtask Queue
- Iterators and Generators
Module 6: The Document Object Model (DOM)
- Introduction to the DOM
- Selecting and Manipulating DOM Elements
- Handling Events
- Propagation, Delegation and Custom Events
- Creating and Removing DOM Elements
- Rendering Lists and HTML Templates
- Handling and Validating Forms
Module 7: Browser APIs and Advanced Topics
- Local and Session Storage
- The Fetch API and AJAX
- Robust Requests: Errors, Timeouts and AbortController
- WebSockets
- Service Workers and Progressive Web Apps (PWAs)
- Essential Browser APIs
- Introduction to WebAssembly
Module 8: Testing and Debugging
- Debugging JavaScript
- Code Quality: ESLint, Prettier and Conventions
- Unit Testing with Jest
- Test Doubles: Mocks, Stubs and Spies
- Integration Testing
- End-to-End Testing with Cypress
Module 9: Performance and Optimization
- Measure Before You Optimize: DevTools and Web Vitals
- Optimizing JavaScript Performance
- Memory Management
- Efficient DOM Manipulation
- Lazy Loading and Code Splitting
Module 10: JavaScript Frameworks and Libraries
- Why Frameworks Exist
- Introduction to React
- State Management with Redux
- Vue.js Basics
- Angular Basics
- Choosing the Right Framework
