Callbacks
Learn async callbacks, error-first callback conventions, callback hell, inversion of control, and why mixing sync and async callback timing causes bugs.
- 01Trace async callback orderPredict which logs happen now and which happen after the host calls back.
- 02Handle errors safelyUse the Node-style
callback(err, result)convention without double-running success code. - 03Recognize callback risksSpot callback hell, inversion of control, and Zalgo timing bugs before they bite.
Callbacks that finish later
A callback is a function you pass to another function so it can be called at the right time. You already met the synchronous version in Function expressions & callbacks: array methods call your function while the array method is still running. This lesson moves that idea into asynchronous work: timers, events, file reads, network requests, and any job whose answer arrives after your current code has moved on.
The key question changes from “what does this function return?” to “who will call my function, with what arguments, and when?” That is a powerful pattern, but it comes with hazards: nested pyramids, error conventions, trust, and timing bugs.
An async callback is a function you pass to an API so the API can call it later, when an operation finishes or fails.
Dropping off a laptop does not mean you stand at the counter for three hours. You leave your number and keep living your life. The callback is that phone number: “when the work is done, call me here.”
- In real life: You leave your phone number at a repair shop
- In JavaScript: You pass a callback function to an async API
- In real life: The shop keeps working while you go home
- In JavaScript: The host API waits while your script keeps running
- In real life: They call when the laptop is ready
- In JavaScript: The API invokes your callback with the result
Where the analogy stops: A repair shop is a person you can negotiate with. A JavaScript API follows its documented timing and argument rules exactly, even when those rules are inconvenient.
In this lesson, every interactive run uses real JavaScript values. The replay players show recorded, instrumented examples; the playgrounds use real setTimeout callbacks and clean up on Reset.
Async callbacks: “call me when it’s done”
STEP THROUGHSynchronous code runs now. An asynchronous callback runs later, after the current script has finished and the host has something to report. The host might be a browser timer, a click event, or Node’s file system. The important part is that the callback is not magic: it is still a function call, just made by someone else later.
Predict the log order. The callback is real JavaScript, but this player replays the recorded order so you can inspect each moment.
script
setTimeout(() => { console.log("repair shop calls back");}, 0);console.log("walk home");Notice that setTimeout does not pause the script. It registers a function and returns. The current code logs the next line, then the host calls the callback in a later task. That is why the event loop lesson matters: callbacks run later only after JavaScript finishes the current turn.
[1, 2].map((n) => n * 2)setTimeout(() => console.log('later'), 0)buttons.forEach((button) => wire(button))fs.readFile('notes.txt', (err, text) => { ... })items.sort((a, b) => a.rank - b.rank)button.addEventListener('click', onClick)
Sort each callback by when the receiving API calls it. If the callback runs before the API returns, it is synchronous.
Error-first callbacks: the result has two seats
INTERACTIVEIn many Node-style APIs, the callback receives the error first and the success value second: callback(err, result). If the work succeeds, err is usually null. If the work fails, err is an Error object and the result may be missing or meaningless.
A careful repair shop starts with the bad-news slot. If something went wrong, that is the first thing you hear. If not, the rest of the call can be about the successful result.
- In real life: “Bad news: we could not fix it.”
- In JavaScript:
callback(error) - In real life: “Good news: here is your repaired laptop.”
- In JavaScript:
callback(null, result) - In real life: You stop planning pickup if there is bad news
- In JavaScript:
if (err) return handle(err);
Where the analogy stops: Real conversations are flexible. Error-first callbacks are positional: callers must check the first argument before trusting the second.
function readProfile(id, callback) { setTimeout(() => { if (id === "missing") { callback(new Error("profile not found")); return; } callback(null, { name: "Ada" }); }, 0);}function finish(err, data) { if (err) { showError(err.message); return; } showProfile(data.name);}- profile: Ada
The first callback argument decides the branch: truthy error means stop; falsy error means use the result.
The tiny return after handling an error matters. It exits the callback so the success branch cannot also run. Forgetting it is a common “double behavior” bug: the UI shows an error and then tries to render missing data.
In Node, fs.readFile("notes.txt", (err, text) => { ... }) is a real-world error-first callback API. You do not need Node on this page; the important convention is the first argument: check err before using text.
Callback hell: the pyramid of doom
REAL TIMERSCallback hell happens when each async step is written inside the previous step’s callback. The right edge marches across the screen, error handling repeats, and the actual story hides in punctuation.
Imagine a to-do list where each completed task reveals the next list inside it. The work still gets done, but it is hard to see the whole plan or change one step safely.
- In real life: Each task reveals the next task
- In JavaScript: Each callback starts the next callback
- In real life: The final task is hard to see
- In JavaScript: The final success path is deeply indented
- In real life: Separate tasks are easier to read
- In JavaScript: Named handlers flatten the flow
Where the analogy stops: Nested callbacks are not always wrong. A two-level example can be perfectly readable. The smell is repeated nesting plus repeated error exits.
function orderPizza(userId, done) { getUser(userId, (userErr, user) => { if (userErr) return done(userErr); getOrders(user.id, (ordersErr, orders) => { if (ordersErr) return done(ordersErr); getDelivery(orders[0].id, (deliveryErr, delivery) => { if (deliveryErr) return done(deliveryErr); done(null, delivery.eta); }); }); });}- Press Run to order a pizza.
Success path: each callback starts the next async operation. The named version keeps the same behavior but removes the pyramid shape.
Named functions do not remove callbacks; they remove the visual pyramid. Promises, in the next lessons, go further: they make “later” into a value you can return, chain, combine, and catch. Lesson 22.6 will show how to promisify this exact error-first style.
Inversion of control: you hand someone the keys
INTERACTIVEPassing a callback means giving another function control over your continuation. You trust it to call your callback once, at the right time, with the right arguments. Most standard APIs do. Some third-party code might call it twice, call it too early, or never call it at all.
The keys let the contractor finish work while you are away, but they also transfer control. Defensive code sets boundaries: only one completion counts, and silence eventually becomes a timeout.
- In real life: You give a contractor your keys
- In JavaScript: You pass a callback to a library
- In real life: You expect one visit, not zero or three
- In JavaScript: You expect exactly one callback call
- In real life: You set boundaries and a deadline
- In JavaScript: Use
once()and a timeout guard
Where the analogy stops: Good APIs document and honor their callback contract. Guards are most useful at boundaries you do not fully control.
function once(callback) { let called = false; return (...args) => { if (called) return; called = true; callback(...args); };} function withTimeout(callback, ms) { const safeCallback = once(callback); const timer = setTimeout(() => { safeCallback(new Error("timed out")); }, ms); return (...args) => { clearTimeout(timer); safeCallback(...args); };}- callback: success
The once guard ignores a second call, and the timeout guard turns silence into a controlled error.
A once() guard is small but powerful: the first call wins, later calls are ignored. A timeout guard turns “nothing happened” into a normal error path, so your UI is not left waiting forever.
Never mix sync and async callbacks (Zalgo)
STEP THROUGH“Zalgo” is the nickname for an API that sometimes calls your callback before it returns and sometimes calls it later. A common cause is a cache: cache hits are available now, cache misses require async work. That inconsistent timing breaks code that expects to finish setup after calling the function.
When you place a food order, you expect every update after the order is placed. An app that sometimes sends an update during checkout and sometimes later makes the next step hard to predict.
- In real life: Every update arrives after you place the order
- In JavaScript: Every callback runs asynchronously
- In real life: Some updates arrive immediately
- In JavaScript: A cache hit calls back synchronously
- In real life: One update order avoids surprises
- In JavaScript: Always defer callbacks for this API
Where the analogy stops: Synchronous callbacks are fine when the API promises they are synchronous, like map. The bug is one API switching timing depending on data.
Predict whether the callback runs before or after line 15. The fixed version always defers cache hits.
script
function getName(id, callback) { if (cache.has(id)) { callback(cache.get(id)); // sync on cache hit return; } setTimeout(() => { callback("Grace Hopper"); }, 0);} console.log("before");getName("ada", (name) => console.log(name));console.log("after");The fix is not “make everything slow.” The fix is consistency. If an API is asynchronous, always call its callback asynchronously, even when the answer is cached. queueMicrotask or setTimeout can defer the cache-hit branch; the exact tool depends on the API’s contract.
Where you’ll use callbacks
Modern code often prefers promises, but callbacks are still everywhere because functions are the basic way JavaScript represents “do this later.” You will see them in event listeners, timers, array methods, older Node APIs, test helpers, streams, and third-party libraries.
| Problem | Callback-era technique | Promise-era preview |
|---|---|---|
| A result arrives later | Pass done(err, value) | Return a promise that fulfills with the value |
| Repeated nested steps | Name each handler and return after errors | Chain with .then(...) or use async functions later |
| Error path repeats everywhere | Use error-first callbacks consistently | Use .catch(...) for one downstream error path |
| Library might call twice | Wrap with once() | Promises settle once by design |
| Sync/async timing changes | Always defer callbacks for async APIs | Promises always run reactions asynchronously |
import { readFile } from "node:fs"; readFile("profile.json", "utf8", (err, text) => { if (err) return console.error(err.message); const profile = JSON.parse(text); console.log(profile.name);});This page does not require Node, but the pattern is worth recognizing: start work, return now, then receive either an error or a result later.
Common misconceptions
“Callback” means “asynchronous.”
No. A callback is any function passed to be called by another function. map callbacks are synchronous; timer, event, and file callbacks are asynchronous.
“setTimeout(fn, 0) runs immediately.”
It schedules fn for later. The current script still runs to completion first.
“Throwing inside an async callback is how you report expected failure.”
Error-first APIs report failure by passing an error to the callback. A throw in a later callback does not travel back to the original caller’s try block.
“Callback hell is just ugly formatting.”
The indentation is a symptom. The deeper problem is tangled sequencing, duplicated error handling, and hard-to-reuse inner steps.
“If a result is cached, calling back synchronously is a harmless optimization.”
It changes the API’s timing contract. Make async APIs consistently async.
Practice: callbacks
5 EXERCISESPredict the order before running the code.
console.log("A");
setTimeout(() => console.log("B"), 0);
console.log("C");The output is A, then C, then B. The callback passed to setTimeout runs after the current script finishes.
Take the pizza pyramid from the experiment and rewrite it as named handlers.
function handleUser(err, user) {
if (err) return finish(err);
getOrders(user.id, handleOrders);
}
function handleOrders(err, orders) {
if (err) return finish(err);
getDelivery(orders[0].id, handleDelivery);
}
function handleDelivery(err, delivery) {
if (err) return finish(err);
finish(null, delivery.eta);
}The behavior is still callback-based, but each step now has a name. You can read the success path from top to bottom instead of right to left.
Fill in a callback that handles the error branch before the success branch.
function finish(err, result) {
if (err) {
console.log("Error: " + err.message);
return;
}
console.log("Result: " + result);
}
finish(null, "saved");
finish(new Error("offline"));function finish(err, result) {
if (err) {
console.log("Error: " + err.message);
return;
}
console.log("Result: " + result);
}
finish(null, "saved");
finish(new Error("offline"));The first call has err === null, so it prints Result: saved. The second call passes an Error, so it prints Error: offline and returns before success code.
Protect a callback so only the first call can do work.
function once(callback) {
let called = false;
return function safeCallback(value) {
if (called) return;
called = true;
callback(value);
};
}
const logOnce = once((value) => console.log(value));
logOnce("first");
logOnce("second");function once(callback) {
let called = false;
return function safeCallback(value) {
if (called) return;
called = true;
callback(value);
};
}
const logOnce = once((value) => console.log(value));
logOnce("first");
logOnce("second");The wrapper remembers whether it has run. first prints; the second call is ignored because called is already true.
Change the cache-hit branch so it calls the callback later, not now.
const cache = new Map([["ada", "Ada"]]);
function getName(id, callback) {
if (cache.has(id)) {
setTimeout(() => callback(cache.get(id)), 0);
return;
}
setTimeout(() => callback("missing"), 0);
}
console.log("before");
getName("ada", (name) => console.log(name));
console.log("after");const cache = new Map([["ada", "Ada"]]);
function getName(id, callback) {
if (cache.has(id)) {
setTimeout(() => callback(cache.get(id)), 0);
return;
}
setTimeout(() => callback("missing"), 0);
}
console.log("before");
getName("ada", (name) => console.log(name));
console.log("after");The cached value is deferred with setTimeout, so the script prints before, then after, then Ada. Cache hit and cache miss now have consistent async timing.
Quiz: check your understanding
7 QUESTIONSQuestion 1 of 7What makes a callback asynchronous?
Choose an answer to see the explanation.
Question 2 of 7What does this print?
Read the code, then predictconsole.log("A"); setTimeout(() => console.log("B"), 0); console.log("C");Choose an answer to see the explanation.
Question 3 of 7In
callback(err, result), what shoulderrbe when the operation succeeds?Choose an answer to see the explanation.
Question 4 of 7Why do examples often write
if (err) return done(err);?Choose an answer to see the explanation.
Question 5 of 7Which risk is inversion of control?
Choose an answer to see the explanation.
Question 6 of 7What is wrong with a function that sometimes calls its callback now and sometimes later?
Choose an answer to see the explanation.
Question 7 of 7Which lesson fixes many callback problems by representing a future value?
Choose an answer to see the explanation.
Key takeaways
- A callback is a function passed to another function; async callbacks are called later when work finishes.
- Error-first callbacks use
callback(err, result); always check and return from the error branch before using the result. - Callback hell is nested sequencing plus repeated error handling; named handlers flatten it, and promises improve it further.
- Inversion of control means another API controls if and when your callback runs; guards like
once()and timeouts protect boundaries. - Never let one async API sometimes call back synchronously and sometimes asynchronously. That timing bug is Zalgo.
Remember the one-liner.
A callback is a function you hand to someone else with instructions for when the work finishes.
Up next: Promises, the fix for many callback problems.