Callbacks
Learn async callbacks, error-first callback conventions, callback hell, inversion of control, and why mixing sync and async callback timing causes bugs.
- 01Predict when a callback runsTell which logs print now, and which print later, when the browser calls your callback.
- 02Handle errors safelyUse the Node style
callback(err, result), and stop the success code from running after an error. - 03Spot callback problemsRecognize callback hell, inversion of control, and Zalgo timing bugs, and know how to fix each one.
Callbacks that finish later
A callback is a function you pass to another function. The other function calls it for you, at the right time.
You met callbacks before, in Function expressions & callbacks. There, array methods like map called your function straight away, while they were still running.
This lesson is about callbacks that run later. For example: a timer that ends, a click on a button, a file that finishes loading, or an answer from a server. In each case, the answer arrives after the rest of your code has moved on.
So the question changes. You don't ask "what does this function return?" You ask three things:
- Who will call my function?
- What values will it pass in?
- When will it call it?
Callbacks are very useful, but they can go wrong in a few ways. This lesson covers each one: code nested deeper and deeper, handling errors, trusting other people's code, and timing bugs.
An async callback is a function you pass to other code, so that code can call it later, when a job finishes or fails. "Async" is short for asynchronous. It means "happens later, not right now".
function greetUser(name, done) {
done("Hello, " + name);
}
greetUser("Asha", (message) => console.log(message));Line 1 makes a function, greetUser. It takes a name and a function called done. Line 2 calls done straight away with a greeting. Line 5 calls greetUser and passes in a small arrow function as done.
It prints Hello, Asha before greetUser finishes. A callback that runs before the other function finishes is called synchronous, or "sync".
You drop off a broken laptop at a repair shop. You don't stand at the counter for three hours. You leave your phone number and go home. When the laptop is ready, the shop calls you. The callback is your phone number: "when the job is done, call me here."
- In real life: You leave your phone number at a repair shop
- In JavaScript: You pass a callback to a function like
setTimeout - In real life: The shop works on your laptop while you go home
- In JavaScript: The browser waits while the rest of your script runs
- In real life: The shop calls you when the laptop is ready
- In JavaScript: The browser calls your callback with the result
Every example on this page runs real JavaScript. The step-through players replay a recorded run, one step at a time. The playgrounds use real setTimeout timers, and Reset stops any timer that is still waiting.
Async callbacks: "call me when it's done"
STEP THROUGHSync code runs now, one line after another. An async callback runs later: after the current script has finished, when the host has something to report.
The host is the program that runs your JavaScript, like the browser or Node. It looks after timers, clicks, and files. An async callback is not magic. It is still a normal function call. The only difference is that the host makes the call, and it makes it later.
Guess the order of the three logs first. This player replays a real run, one step at a time.
script
setTimeout(() => { console.log("repair shop calls back");}, 0);console.log("walk home");setTimeout does not pause the script. It hands your function to the host and finishes at once. So the script goes on to line 5 and prints walk home. Only after the whole script is done does the host call your callback. The event loop lesson explains why callbacks wait for the current script to finish.
function loadGreeting(done) {
setTimeout(() => done("Hello, Asha"), 0);
}
loadGreeting((message) => console.log(message));Line 1 makes loadGreeting, which takes one callback, done. Line 2 starts a timer, and loadGreeting finishes straight away, without a greeting. Later the timer calls done with "Hello, Asha". The callback from line 5 then decides what to do with the greeting: here, it prints it.
A plain return can't work here. The function returns before the timer ends, so it has no greeting to give back yet.
[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 it runs. If it runs before the function you passed it to finishes, it is sync. If it runs later, it is async.
const shoppingList = ["tea", "soap"];
const food = shoppingList.filter((item) => item === "tea");
console.log(food.join(", "));Line 2 runs filter. It calls your callback for "tea" and then for "soap", before filter finishes. Only "tea" passes the test, so line 3 prints tea. This is still a callback. It just runs now, not later.
Error-first callbacks: error first, result second
INTERACTIVEMany Node functions call your callback with two values, in this order:
- First, the error. If the job worked, this is
null, which means "no error". - Second, the result. It is only there if the job worked.
So the callback looks like callback(err, result). This style is called an error-first callback. If the job fails, err is an Error object, and the result is missing or can't be trusted.
A good repair shop tells you about any problem first. If something went wrong, that is the first thing you hear. If nothing went wrong, the rest of the call is about your fixed laptop.
- In real life: "Sorry, we could not fix it."
- In JavaScript:
callback(error) - In real life: "Good news: your laptop is fixed."
- In JavaScript:
callback(null, result) - In real life: If the news is bad, you don't go to pick it up
- In JavaScript:
if (err) return handle(err);
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 value decides the branch. If there is an error, stop. If the error is null, use the result.
Look at the short return inside the error branch. It stops the callback, so the success code can't run too. People often forget it. Then the page shows an error, and also tries to show data that isn't there.
function saveNote(text, done) {
if (!text) return done(new Error("empty note"));
done(null, "saved");
}
saveNote("tea", (err, result) => {
if (err) return console.log("error: " + err.message);
console.log(result);
});
saveNote("", (err, result) => {
if (err) return console.log("error: " + err.message);
console.log(result);
});null, the callback uses the result.The first call passes "tea", so saveNote calls back with null as the error. The callback prints saved.
The second call passes an empty text, so saveNote calls back with an Error. The callback prints error: empty note and returns before it touches the result. Don't trust the result until you have checked err.
In Node, fs.readFile("notes.txt", (err, text) => { ... }) reads a file and calls back in the error-first style. You don't need Node for this page. Just remember the rule: check err before you use text.
Callback hell: the pyramid of doom
REAL TIMERSSometimes one job must wait for another. For example, you need the user before you can load their orders. With callbacks, you start each next job inside the callback of the job before it.
Each new step adds more indentation, the space at the start of a line. So the code moves further right with every step. This shape is nicknamed callback hell, or the pyramid of doom. It causes three problems:
- The code drifts to the right and gets hard to read.
- Every step repeats the same error check.
- The real story (user, then orders, then delivery) hides among the brackets.
Picture a to-do list where finishing one task shows you another list inside it. The work still gets done. But it is hard to see the whole plan, or to change one step without breaking another.
- In real life: Each finished task shows you the next task
- In JavaScript: Each callback starts the next job
- In real life: The last task is buried deep inside
- In JavaScript: The final success line is indented far to the right
- In real life: Tasks on one flat list are easier to read
- In JavaScript: Named functions keep the steps flat
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.
Every step works: each callback starts the next step. The named version does exactly the same thing, without the pyramid shape.
Named functions don't remove the callbacks. They only remove the pyramid shape: each step gets a name and sits at the left edge.
Promises, in the next lessons, go further. A promise turns "a result that arrives later" into a value you can return, chain, and catch errors on. The Promisification lesson shows how to turn this exact error-first style into promises.
Inversion of control: you hand someone the keys
INTERACTIVEWhen you pass a callback, you let other code decide when your next step runs. This is called inversion of control: control moves from your code to the other code. You trust that code to call your callback:
- once,
- at the right time,
- with the right values.
Built-in functions like setTimeout do this reliably. Code written by other people, called a library, might not. It could call your callback twice, too early, or never.
The keys let the builder finish the work while you are away. But now the builder decides when to come in. So you set rules: only one visit counts, and if nobody comes by a deadline, you treat that as a problem.
- In real life: You give a builder your house 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 your callback to run exactly once
- In real life: You set rules and a deadline
- In JavaScript: Use
once()and a time limit
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. The time limit turns "nothing happened" into an error you can handle.
A once() guard is a small wrapper around your callback. The first call runs your callback. Every later call is ignored.
A time limit, or timeout, handles the other case. If nothing happens in time, it calls your callback with an error. Then your page doesn't wait forever.
function once(callback) {
let called = false;
return (value) => {
if (called) return;
called = true;
callback(value);
};
}
const logOnce = once((value) => console.log(value));
logOnce("first");
logOnce("second");Line 2 makes a flag, called, set to false. Lines 3 to 7 return a new function that checks the flag. Line 11 calls it for the first time. The flag is still false, so it becomes true and first is printed. Line 12 calls it again. Now the flag is true, so line 4 returns and nothing prints.
The new function can still see called after once has finished. It keeps access to the variables around it; this is called a closure. The guard stops double calls. It can't fix a library that passes the wrong values, so check those values too.
Never mix sync and async callbacks (Zalgo)
STEP THROUGHSome functions call your callback right away in some cases, and later in others. Programmers have a nickname for this: Zalgo.
A common cause is a cache, a store of answers you already have:
- If the answer is in the cache (a cache hit), the function can call back right away.
- If it isn't (a cache miss), the function must fetch the answer first, and call back later.
This uneven timing breaks the code that runs right after the call. That code can't know whether the callback has run yet.
When you order food in an app, you expect every update to come after you place the order. If the app sometimes sent an update while you were still paying, and sometimes later, you couldn't plan your next step.
- In real life: Every update comes after you place the order
- In JavaScript: The callback always runs later
- In real life: One update arrives while you are still paying
- In JavaScript: A cache hit calls back right away
- In real life: Updates in the same order every time are easy to follow
- In JavaScript: Always call back later in this function
Guess whether the name prints before or after line 15's after. The fixed version always delays 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 to make everything slow. The fix is to make it the same every time. If a function calls back later in one case, it should call back later in every case, even when the answer is in the cache.
queueMicrotask or setTimeout can delay the cache-hit callback. queueMicrotask(fn) runs fn as soon as the current script finishes. setTimeout(fn, 0) runs it a little later. Either one works here.
Where you'll use callbacks
Newer code often uses promises instead. But callbacks are still everywhere, because a function is how JavaScript says "do this later". You'll see them in:
- event listeners, like a click on a button,
- timers, like
setTimeout, - array methods, like
mapandfilter, - older Node functions, like
fs.readFile, - test tools and libraries written by other people.
The table below lists each callback problem from this lesson. Read each row as: "this goes wrong, here is the callback fix, and here is how promises handle it."
| What goes wrong | The fix with callbacks | How promises handle it (next lessons) |
|---|---|---|
| A result arrives later. | Pass a callback like done(err, value). | Return a promise that gives the value when it is ready. |
| Steps nest deeper and deeper. | Give each step a named function, and return after errors. | Chain the steps with .then(...), or use async functions. |
| The same error check repeats at every step. | Use error-first callbacks every time. | Use one .catch(...) at the end of the chain. |
| A library might call your callback twice. | Wrap the callback with once(). | A promise can only finish once. |
| The callback sometimes runs now and sometimes later. | Always call back later in async functions. | Promise callbacks always run later. |
A completion callback belongs to one job, like reading one file. It should run once. An event listener is a callback for something that can happen many times, like clicks. It may run again and again. Use once for the first kind. Don't use it to block real clicks.
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);});Line 3 starts reading profile.json and gives readFile a callback. readFile returns at once. Later, Node calls the callback with an error or with the file's text. Line 4 handles the error and stops. Line 6 turns the text into an object with JSON.parse, and line 7 prints the name.
You don't need Node for this page. But you'll see this pattern often: start a job, return now, and get an error or a result later.
Common misconceptions
"A callback always runs later."
No. A callback is any function you pass to another function to call. map calls its callback right away. Timers, events, and file reads call theirs later.
"setTimeout(fn, 0) runs right away."
No. It runs fn later. The current script always finishes first, even with a delay of 0.
"To report a failure from an async callback, throw an error."
No. Error-first functions report a failure by passing an error to the callback. If a later callback throws, the error can't reach the try block around the first call, because that call finished long ago.
"Callback hell is only about messy spacing."
No. The deep indentation is just the part you can see. The real problems are steps that are hard to follow, the same error check repeated, and inner steps you can't reuse.
"If the answer is in a cache, calling back right away is a harmless speed-up."
No. It changes when the callback runs, and the code after the call depends on that. If a function calls back later in one case, make it call back later in every case.
Practice: callbacks
5 EXERCISESGuess the order of the three logs, then run 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 rest of the script is done.
Take the pizza pyramid from the playground above. Rewrite it with named functions, one for each step.
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);
}It still uses callbacks, but now each step has a name. You can read the steps from top to bottom, instead of following them deeper and deeper to the right.
Write a callback that handles the error first and the result second.
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 passes null as err, so it prints Result: saved. The second call passes an Error, so it prints Error: offline and returns before the success line.
Protect a callback so only its first call does anything.
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. The first call prints first. 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 name now waits for setTimeout, so the script prints before, then after, then Ada. A cache hit and a cache miss now both call back later.
Quiz: check your understanding
7 QUESTIONSFor each question, first ask: does the callback run now, or later? Each answer explains why it is right or wrong.
Question 1 of 7What makes a callback async (run later)?
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 job works?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 problem 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 with a value for a result that arrives later?
Choose an answer to see the explanation.
Key takeaways
- A callback is a function you pass to another function. An async callback is called later, when a job finishes.
- Error-first callbacks look like
callback(err, result). Check the error first, and return from that branch before you use the result. - Callback hell is steps nested inside steps, with the same error check repeated. Named functions flatten it, and promises fix more of it.
- Inversion of control means other code decides if and when your callback runs. A
once()guard and a time limit protect you. - A function should never call back right away in some cases and later in others. That timing bug is called Zalgo.
Remember the one-liner.
A callback is a function you hand to other code, to call when the job is done.
Up next: Promises, which fix many callback problems.