JavaScript Callbacks
A callback is a function you pass to another function, to be invoked later. It’s the oldest async pattern in JavaScript and still pervasive — every setTimeout, every addEventListener, every array .map(fn) takes a callback. Understanding them is non-negotiable, even though we now reach for promises and async/await for new async work.
Synchronous Callbacks
The simplest form — the callback runs before the outer function returns.
[1, 2, 3].forEach(n => console.log(n)); // arrow is the callback
["c", "a", "b"].sort((a, b) => a.localeCompare(b));
const evens = [1, 2, 3, 4].filter(n => n % 2 === 0);
The array methods from chapter 19 (map, filter, reduce, find, …) all take synchronous callbacks. You don’t usually call them “callbacks” — but technically that’s what they are.
Asynchronous Callbacks
The classic pattern: kick off something slow, and let it call you back when done.
setTimeout(() => {
console.log("ran after 1s");
}, 1000);
document.querySelector("#btn").addEventListener("click", e => {
console.log("clicked");
});
// Node.js — read a file
import { readFile } from "node:fs";
readFile("./data.json", "utf8", (err, text) => {
if (err) return console.error(err);
console.log(text);
});
The Node-Style (err, result) Convention
In Node and many older browser libs, async callbacks follow error-first convention:
function asyncWork(input, callback) {
setTimeout(() => {
if (!input) return callback(new Error("input required"));
callback(null, input.toUpperCase());
}, 100);
}
asyncWork("hello", (err, result) => {
if (err) {
console.error(err);
return;
}
console.log(result); // "HELLO"
});
Two rules:
- First arg is
err(nullon success) - Subsequent args are the result(s)
This convention lets every callback share a uniform error-handling shape.
Writing a Function That Takes a Callback
function fetchUser(id, callback) {
setTimeout(() => {
if (id < 0) return callback(new Error("Invalid id"));
callback(null, { id, name: "Ada" });
}, 200);
}
fetchUser(42, (err, user) => {
if (err) return console.error(err);
console.log(user);
});
Rules of thumb:
- Call the callback exactly once. Never zero (silent hang), never twice (double-firing handlers).
- Call it asynchronously, even on the synchronous path — use
queueMicrotask(...)orsetTimeout(..., 0)— to avoid surprise sync execution that breaks caller assumptions.
// ❌ Sometimes sync, sometimes async — caller can't tell
function flakyAsync(value, cb) {
if (cache.has(value)) return cb(null, cache.get(value)); // sync
setTimeout(() => cb(null, compute(value)), 100); // async
}
// ✅ Always async
function consistentAsync(value, cb) {
if (cache.has(value)) {
queueMicrotask(() => cb(null, cache.get(value)));
return;
}
setTimeout(() => cb(null, compute(value)), 100);
}
Callback Hell
When you need to chain several async steps, naive nested callbacks become a sideways pyramid:
loadUser(1, (err, user) => {
if (err) return console.error(err);
loadOrders(user.id, (err, orders) => {
if (err) return console.error(err);
loadItems(orders[0].id, (err, items) => {
if (err) return console.error(err);
computeTotal(items, (err, total) => {
if (err) return console.error(err);
console.log("total", total);
});
});
});
});
Three problems:
- Visual indent — keeps growing
- Error handling — repeated five times
- Sequencing complex flows — branching, retries, parallel becomes painful
This is the historical pressure that produced Promise and then async/await.
Mitigating Callback Hell
Even with callbacks you can flatten by naming each step:
function start() {
loadUser(1, onUser);
}
function onUser(err, user) {
if (err) return done(err);
loadOrders(user.id, onOrders);
}
function onOrders(err, orders) {
if (err) return done(err);
loadItems(orders[0].id, onItems);
}
function onItems(err, items) {
if (err) return done(err);
computeTotal(items, done);
}
function done(err, total) {
if (err) return console.error(err);
console.log("total", total);
}
start();
Flatter, but the error handling is still scattered. Promises handle it once at the chain’s end.
Promisifying Callback APIs
Wrap a callback-style function to return a promise:
function promisify(fn) {
return (...args) =>
new Promise((resolve, reject) => {
fn(...args, (err, result) => {
if (err) return reject(err);
resolve(result);
});
});
}
const fetchUserP = promisify(fetchUser);
const user = await fetchUserP(42);
console.log(user);
Node ships this as util.promisify; most modern Node APIs also offer promise variants in their node:fs/promises / node:dns/promises modules.
Common Callback Patterns You Still See
// Event handler
btn.addEventListener("click", onClick);
// Timer
setTimeout(callback, 1000);
setInterval(callback, 1000);
// Array helpers
[1, 2, 3].map(fn).filter(fn).reduce(fn, init);
// Sort comparator
items.sort((a, b) => a.id - b.id);
// requestAnimationFrame
requestAnimationFrame(function tick(t) {
// …
requestAnimationFrame(tick);
});
// Observer
const obs = new MutationObserver(records => { /* … */ });
// Iterator-style callbacks (Node streams)
stream.on("data", chunk => { /* … */ });
stream.on("end", () => { /* … */ });
stream.on("error", err => { /* … */ });
A Realistic Example — Retry with Backoff (Callback Style)
Most modern code would use promises, but seeing the same logic in callback form makes the comparison clear:
function retry(fn, { attempts = 3, baseDelayMs = 200 } = {}, callback) {
let attempt = 0;
function tryOnce() {
fn((err, result) => {
if (!err) return callback(null, result);
attempt++;
if (attempt >= attempts) return callback(err);
const delay = baseDelayMs * 2 ** (attempt - 1);
setTimeout(tryOnce, delay);
});
}
tryOnce();
}
retry(cb => loadConfig(cb), { attempts: 5 }, (err, config) => {
if (err) return console.error("gave up", err);
console.log("got", config);
});
Compare with the promise version (next chapter) — same logic, half the brain power to read.
Common Mistakes
// ❌ Calling the callback twice
function f(cb) {
doWork((err, result) => {
cb(err, result);
if (extraCheck()) cb(null, "extra"); // ✗ second call
});
}
// ❌ Forgetting to handle the error path
loadThing((err, result) => {
use(result); // ignores err — silently broken
});
// ❌ Synchronous "callback" that throws — caller can't recover normally
function flaky(cb) {
try {
cb(null, JSON.parse(somethingBad)); // throw bubbles into caller's stack
} catch (e) { cb(e); }
}
// ❌ Using `this` inside a callback
const obj = {
name: "Ada",
greet() {
setTimeout(function () {
console.log(this.name); // undefined — `this` lost
}, 100);
},
};
// ✅ Arrow keeps outer this
const obj = {
name: "Ada",
greet() {
setTimeout(() => console.log(this.name), 100);
},
};
Best Practices Checklist
- ✅ Use error-first
(err, result)convention for new callback APIs - ✅ Call the callback exactly once, always asynchronously
- ✅ Promisify callback APIs at the boundary so the rest of your code is
async/await - ✅ Use arrows for callbacks that need outer
this - ✅ Name each step in chains; flatten visually
- ❌ Don’t nest more than two callbacks deep — promisify or refactor
- ❌ Don’t write functions that are sync sometimes and async other times
- ❌ Don’t swallow errors silently
What’s Next?
You understand the callback world and its pain points. The fix arrived in ES2015: promises — values that represent “a result that isn’t ready yet”. Next chapter shows how they tame async flow.