Learn JS Series (#19) - Callbacks and the Callback Pattern (Before We Reach Promises)

What will I learn
- You will learn what a callback actually is, and the two distinct jobs a callback can do;
- the difference between synchronous and asynchronous callbacks, and why confusing them causes real bugs;
- the error-first callback convention that early Node built its entire standard library on;
- what "callback hell" is, why deeply nested callbacks become unmanageable, and one way people tamed it before Promises existed;
- why understanding callbacks first makes Promises and async/await (Phase 6) click instantly instead of feeling like magic.
Requirements
- A working modern computer running macOS, Windows or Ubuntu;
- An installed Node.js (20+) distribution, or just a modern browser console;
- Episodes 1-18 read, especially first-class functions (ep15), closures (ep17) and higher-order functions (ep18).
Difficulty
- Beginner
Curriculum (of the Learn JS Series):
- Learn JS Series (#1) - What Is JavaScript, Why It Runs Everywhere, and How to Run It
- Learn JS Series (#2) - Variables and Bindings
- Learn JS Series (#3) - The Primitive Types: number, string, boolean, null, undefined, symbol, bigint
- Learn JS Series (#4) - Operators and Expressions: Arithmetic, Comparison, Logical, and Short-Circuiting
- Learn JS Series (#5) - Strings: Template Literals, Unicode, and the Methods You Actually Use
- Learn JS Series (#6) - Numbers: IEEE 754, Why 0.1 + 0.2 Is Not 0.3, and How to Cope
- Learn JS Series (#7) - Control Flow: if/else, switch, and the Ternary Expression
- Learn JS Series (#8) - Loops: for, while, for...of, for...in, and When to Use Which
- Learn JS Series (#9) - Functions: Declarations, Parameters, Return Values, and Hoisting
- Learn JS Series (#10) - Scope and the Temporal Dead Zone: How JavaScript Finds Your Variables
- Learn JS Series (#11) - Arrays: The Workhorse Data Structure and Its Core Methods
- Learn JS Series (#12) - Objects: Key-Value Data, Dot vs Bracket Access, and Nesting
- Learn JS Series (#13) - Truthiness, Equality, and Coercion: == vs === Done Properly
- Learn JS Series (#14) - Mini Project: A Command-Line Tip Calculator
- Learn JS Series (#15) - First-Class Functions: Passing, Returning, and Storing Functions
- Learn JS Series (#16) - Arrow Functions vs function: Syntax, this, and When Each Wins
- Learn JS Series (#17) - Closures: The Single Most Important Idea in JavaScript
- Learn JS Series (#18) - Higher-Order Functions: Functions That Take or Return Functions
- Learn JS Series (#19) - Callbacks and the Callback Pattern (Before We Reach Promises) (this post)
Learn JS Series (#19) - Callbacks and the Callback Pattern (Before We Reach Promises)
Solutions to Episode 18 Exercises
Exercise 1 - transforming an object's values:
function mapObject(obj, fn) {
const out = {};
for (const [key, value] of Object.entries(obj)) {
out[key] = fn(value);
}
return out;
}
console.log(mapObject({ a: 1, b: 2, c: 3 }, (n) => n * 2)); // { a: 2, b: 4, c: 6 }
The insight: we loop the entries, apply fn to each value, and build a fresh object -- map for objects, which JavaScript does not provide built-in.
Exercise 2 - a call-counting decorator:
function withCount(fn) {
let count = 0;
const wrapped = (...args) => {
count++;
return fn(...args);
};
wrapped.count = () => count;
return wrapped;
}
const hi = withCount(() => "hi");
hi(); hi();
console.log(hi.count()); // 2
The insight: the closure variable count survives between calls, and we expose it via a method attached to the wrapper -- higher-order functions and closures working together, exactly as episode 18 promised.
Exercise 3 - filter then transform:
function filterAndTransform(array, keep, transform) {
return array.filter(keep).map(transform);
}
console.log(filterAndTransform([1, 2, 3, 4], (n) => n % 2 === 0, (n) => n * n)); // [4, 16]
The insight: two behaviours passed in (a test and a transform) let one generic function serve countless specific needs. The bonus reduce version does both jobs in a single pass, which is worth doing once by hand to convince yourself the answer matches.
Now we take functions-as-arguments and point them at their most famous job of all: handling work that finishes later.
Learn JS Series (#19) - Callbacks and the Callback Pattern (Before We Reach Promises)
What a callback is
A callback is a function you pass to another function, so that the other function can "call you back" at the right moment. That is genuinely all it is -- a higher-order function argument (episode 18) given a job-specific name. You have been writing callbacks since episode 11: the function you hand to map, filter, or forEach is a callback.
[1, 2, 3].forEach((n) => console.log(n)); // the arrow is a callback
forEach calls your function once per element. You did not call it yourself -- you handed it over, and forEach "called you back" three times. The function you pass is often called a higher-order argument in theory and a callback in practice; the two words describe the same object from two angles. Theory says "a function passed as data"; practice says "the thing that gets run when the moment is right".
So far this sounds like a rename of last episode. The reason callbacks earn their own episode is that they come in two distinct flavours that behave very differently in time, and mixing them up is one of the most common sources of confusion for people new to JavaScript. Let's separate them cleanly, because once you see the split, a huge amount of async code stops being mysterious.
The two jobs a callback can do
Every callback you will ever meet is doing one of two jobs:
- Configuration -- you inject a small piece of behaviour so a generic algorithm can do its work right now. The sort comparator, the
maptransform, thefiltertest: these run immediately, during the outer call, and they are finished before the outer function hands control back to you. - Continuation -- you say "when this longer operation finishes, run this". A timer, a network request, a file read, a click. The outer function returns immediately, and your callback fires at some future moment.
Job 1 is a synchronous callback. Job 2 is an asynchronous callback. Same mechanism (a function passed as an argument), completely different relationship with time. Here is job 1 in the wild -- a sort comparator, which the engine calls many times to decide the order, all before sort returns:
const words = ["pear", "fig", "apple", "kiwi"];
words.sort((a, b) => a.length - b.length); // comparator decides the ordering
console.log(words); // ["fig", "kiwi", "pear", "apple"]
The comparator is a callback with a job: "given two items, tell me which comes first". It runs synchronously, again and again, entirely inside the sort call. When console.log runs on the next line, all the calling-back is already done. Hold that picture, because job 2 breaks it.
Synchronous callbacks
A synchronous callback runs immediately, during the call, and finishes before the outer function returns. All the array-method callbacks are synchronous. map calls your function, waits for it, collects the result, and moves on -- all before map returns:
console.log("before");
[1, 2].forEach((n) => console.log(`during: ${n}`));
console.log("after");
// prints: before, during: 1, during: 2, after (in order, no surprises)
Everything happens in the order you read it, top to bottom, left to right. There is no waiting, no "later", no gap in time. Synchronous callbacks are just a way to inject behaviour into a generic mechanism -- exactly the higher-order-function idea from last episode, wearing a different hat. If this were the whole story, callbacks would not need much of an episode. But the interesting, world-changing flavour is the other one.
Asynchronous callbacks
An asynchronous callback is more subtle: it is a function you hand over now, to be called later, after some operation completes. The outer function returns immediately, and your callback fires at some future moment that you do not control precisely. The simplest example is setTimeout, which calls your callback after a delay:
console.log("before");
setTimeout(() => console.log("later"), 1000); // called after ~1 second
console.log("after");
// prints: before, after, later (!) "later" comes LAST
Read that output carefully, because it is the beating heart of asynchronous JavaScript. "later" prints after "after", even though its line comes first, because setTimeout does not wait -- it schedules the callback for the future and immediately lets the rest of the program run. This non-blocking behaviour -- the ability to say "do this later, but keep going for now" -- is what lets a single-threaded language stay responsive instead of freezing while it waits for a slow disk or a slow network.
The mechanism that decides when your deferred callback finally runs is called the event loop, and it is important enough that we dissect it fully in Phase 6. For today the one-sentence version is enough: JavaScript runs your synchronous code to completion first, and only then reaches for the callbacks that became ready while it was busy. That is why a setTimeout(..., 0) -- zero milliseconds! -- still fires after the current line of code, not during it.
Callbacks in the wild: events
Timers are the tidiest example, but the place you will bump into asynchronous callbacks most often is events. Something happens (data arrives, a user clicks, a connection opens) and a function you registered earlier gets called. In Node.js, this is built into the EventEmitter:
const EventEmitter = require("events");
const feed = new EventEmitter();
feed.on("message", (text) => {
console.log("got:", text);
});
feed.emit("message", "hello");
feed.emit("message", "world");
// prints: got: hello then got: world
You register the callback once with feed.on(...), and it runs every time a "message" event is emitted -- zero times, once, or a thousand times, you do not know in advance. In the browser the exact same idea wears the name addEventListener: button.addEventListener("click", handler) hands the browser a callback to run whenever the button is clicked. Different API, identical pattern -- a function handed over now, to be called back later, possibly many times. Events are asynchronous callbacks that do not fire just once but keep firing as long as you are listening.
The error-first convention
Early Node.js was built almost entirely on single-shot asynchronous callbacks (the "call me back exactly once, when this finishes" kind), and it standardized a convention you will still meet all over the ecosystem: the error-first callback. The rule is simple and rigid: an async callback takes the error as its first argument (or null if all went well), and the result as the second. You always check the error first, before you dare to touch the result:
function readConfig(filename, callback) {
// pretend this reads a file asynchronously
setTimeout(() => {
if (filename === "missing.json") {
callback(new Error("file not found"), null); // error first
} else {
callback(null, { theme: "dark", userId: 7 }); // null error, then data
}
}, 100);
}
readConfig("app.json", (err, config) => {
if (err) {
console.error("failed:", err.message);
return; // bail out on error
}
console.log("loaded:", config); // { theme: 'dark', userId: 7 }
});
The if (err) { ...; return; } guard at the top of every callback is the signature of this style -- once you have seen it a few times you will recognise error-first code from across the room. Why first, and not last? Because putting the error where you cannot miss it makes forgetting to handle it awkward, which is exactly what you want. The convention has one serious ergonomic problem, though, and it shows up the moment operations start to depend on one another.
Callback hell
The trouble starts when one async operation must wait for the result of another, which waits for another, which waits for another. Each step nests inside the previous callback, and the code marches diagonally across the screen into a pyramid that is genuinely hard to read, change, and get right. This is affectionately (or bitterly) called callback hell, or the "pyramid of doom":
readConfig("app.json", (err, config) => {
if (err) return console.error(err);
loadUser(config.userId, (err, user) => {
if (err) return console.error(err);
fetchPosts(user.id, (err, posts) => {
if (err) return console.error(err);
renderPage(posts, (err, html) => {
if (err) return console.error(err);
console.log("done", html); // four levels deep!
});
});
});
});
Look at the rightward drift and the repeated if (err) return at every single level. The error handling is duplicated four times, the flow is hard to follow, and inserting a new step in the middle means re-indenting a whole block and threading the error checks correctly -- fiddly, and easy to get subtly wrong. Now imagine ten steps instead of four. This pain was so widespread, and so universally hated, that the language grew a better tool specifically to flatten this pyramid: Promises, and later async/await, both of which we cover in full in Phase 6.
Taming the pyramid the old-fashioned way
Before Promises landed, people did have one honest trick to fight the drift, and it is worth seeing because it reinforces everything from episode 18: a callback is just a function, and a function does not have to be an anonymous arrow written inline. You can pull each step out into a named function and pass it by name, which flattens the pyramid back into a readable list:
function start() {
readConfig("app.json", onConfig);
}
function onConfig(err, config) {
if (err) return console.error(err);
loadUser(config.userId, onUser);
}
function onUser(err, user) {
if (err) return console.error(err);
fetchPosts(user.id, onPosts);
}
function onPosts(err, posts) {
if (err) return console.error(err);
console.log("done", posts);
}
start();
Same four steps, same error-first callbacks, but now the code reads straight down instead of drifting right, and each step has a name you can search for and test in isolation. It is a real improvement -- and yet notice it is not free: the four if (err) return lines are still duplicated, and you now have to jump between four separate functions to follow one logical flow. It softens the pain without curing it. That gap between "softened" and "cured" is exactly the space Promises were invented to fill.
Why learn callbacks first
You might reasonably ask why we study callbacks at all if Promises replaced them for chaining. Two reasons, and both matter.
First, callbacks are not going anywhere. setTimeout, setInterval, event listeners (addEventListener, EventEmitter), and every array method are callback-based and always will be. You cannot write real JavaScript without them, so a fuzzy understanding here becomes a fuzzy understanding of half the language.
Second, and more importantly: Promises and async/await are built on top of the callback idea. They are a nicer, standardized interface over "call me back when you are done" -- underneath, a Promise is still registering callbacks for you and running them when the work settles. Understanding the raw pattern, including its pain, is precisely what makes the elegance of Promises land when you meet them. Here is a taste of where Phase 6 takes us:
// a taste of where we are heading (Phase 6):
// the four-level pyramid above becomes a flat, readable sequence:
//
// const config = await readConfig("app.json");
// const user = await loadUser(config.userId);
// const posts = await fetchPosts(user.id);
// const html = await renderPage(posts);
//
// same work, no nesting, ONE error handler around the whole thing.
When you reach that async/await version and feel how much cleaner it is, you will appreciate it precisely because you felt the pyramid first. Tools make the most sense when you have personally suffered the problem they solve. That is why we start here, in the trenches, before we hand you the ladder.
How other languages handle this
Many of you came from the Learn Python Series (and a few from Rust and Go), so a look sideways helps place the pattern. The callback idea itself is universal, but the way each language deals with the asynchronous flavour -- and with callback hell -- differs in instructive ways.
Python has callbacks in the ordinary sense everywhere (a sort key, a map, a function passed to another function), exactly like our synchronous case:
words = ["pear", "fig", "apple", "kiwi"]
words.sort(key=len) # a callback deciding the order
print(words) # ['fig', 'pear', 'kiwi', 'apple']
print(list(map(lambda n: n * n, [1, 2, 3]))) # [1, 4, 9]
For the asynchronous side, historical Python callback code (in frameworks like Twisted) suffered the very same pyramid of doom, and Python's answer rhymes with JavaScript's: it added async/await on top. So a Pythonista reaching Phase 6 will feel right at home -- both languages converged on the same escape hatch.
Rust uses closures with a |params| body syntax as its callbacks, and its iterator methods take them just like JS array methods do. For async work Rust also lands on async/await, though its futures are lazy and driven by a runtime you choose:
fn main() {
let mut words = vec!["pear", "fig", "apple", "kiwi"];
words.sort_by_key(|w| w.len()); // a closure callback deciding the order
println!("{:?}", words); // ["fig", "pear", "kiwi", "apple"]
}
Same "pass a small function to decide behaviour" shape, with the compiler checking the types up front -- the recurring Rust trade-off of more ceremony now for more guarantees later.
Go is the interesting outlier: it largely sidesteps callback hell instead of flattening it. Rather than "call me back later", a goroutine simply blocks and waits, written top to bottom like synchronous code, while the runtime keeps everything else moving. Coordination happens through channels rather than nested callbacks:
package main
import "fmt"
func main() {
done := make(chan string)
go func() { done <- "work finished" }() // run concurrently
fmt.Println(<-done) // wait for it, no nesting
}
That reads straight down -- no pyramid -- because Go bet on lightweight concurrency at the language level instead of on callbacks plus a later Promise layer. Three languages, three routes: JavaScript and Python bolt async/await over callbacks, Rust does the same with strict typing, and Go trades callbacks for goroutines and channels. Seeing the alternatives is the best way to understand why JavaScript's story turned out the way it did, which is exactly what Phase 6 is about.
Try it yourself
Three exercises, increasing in difficulty. Type them out and run them -- predicting the output and then checking it is where the real learning happens. Full solutions open the next episode.
- Write a function
delay(ms, callback)that usessetTimeoutto callcallbackaftermsmilliseconds with the message"done waiting". Call it, and add aconsole.log("meanwhile")on the very next line. Predict which line prints first, then run it and explain in one sentence why. - Write an error-first async function
divide(a, b, callback)that, after a shortsetTimeout, calls back with anErrorifbis zero, or with the numeric result otherwise. Handle both cases at the call site using theif (err) returnguard. - Take two async operations (you can fake them with
setTimeoutlikereadConfigdoes) and nest them two levels deep, so the second depends on the first's result. Then, in a comment, sketch what the flat async/await version would look like, and explain in one sentence why the nested version gets worse as you add steps.
So what did we actually cover?
- A callback is a function passed to another function so it can be "called back" at the right moment -- a higher-order argument (ep18) with a job-specific name.
- Callbacks do one of two jobs: configuration (synchronous, runs now, like a
sortcomparator ormaptransform) or continuation (asynchronous, runs later, likesetTimeoutor an event). - Synchronous callbacks finish before the outer function returns; asynchronous ones do not, which is why "later" can print after "after" even when its line comes first.
- Events (
EventEmitter,addEventListener) are asynchronous callbacks that fire repeatedly for as long as you are listening. - Node standardized the error-first convention:
callback(err, result), witherrchecked first viaif (err) return. - Chaining dependent async callbacks creates callback hell, a hard-to-read pyramid with duplicated error handling; named functions soften it but do not cure it.
- Callbacks are everywhere and they underpin Promises -- feeling their pain now is what makes Promises and async/await (Phase 6) so satisfying later.
Next episode we step back to function mechanics and cover default, rest, and spread -- the modern syntax for writing flexible function signatures that accept any number of arguments cleanly (and which, not by accident, is exactly what those ...args decorators in episode 18 were quietly using).
See you in the next one -- go break some timers.
@scipio

