The Callback Pattern
Before promises and async/await, Node had callbacks — and they're still everywhere, in core APIs and older libraries. A callback is simply a function you pass into another function to be called later, when the work is done. Node standardized a specific shape — the error-first callback — and learning it unlocks reading and bridging the entire callback-based ecosystem.
The error-first convention
Node callbacks follow one rule: the callback's first argument is the error (or null if all went well), and the result follows. This consistency means you handle failures the same way everywhere:
import { readFile } from 'node:fs'
readFile('config.json', 'utf8', (err, data) => {
if (err) { // 1. ALWAYS check error first
console.error('Failed:', err.message)
return // 2. return so success code doesn't run
}
console.log('Got:', data) // 3. only reached when err is null
})Position | Holds |
|---|---|
1st arg ( |
|
2nd+ args | The result(s) — only meaningful when |
Position | The callback is the last argument to the function |
Why a callback, not a return value?
A synchronous function can return because it has the answer immediately. An async operation doesn't yet — so instead of returning the result, it takes a function and promises to call it once the result exists:
// Sync: the value is ready, so return it
function addSync(a, b) { return a + b }
// Async: the value isn't ready now, so accept a callback for later
function fetchUser(id, callback) {
setTimeout(() => callback(null, { id, name: 'Ada' }), 100)
}
fetchUser(1, (err, user) => console.log(user.name)) // 'Ada', 100ms laterWriting your own error-first function
Follow the convention so your function composes with everything else (and can be promisify-ed later):
function divide(a, b, callback) {
// Defer the callback so it's ALWAYS async (see the warning below)
process.nextTick(() => {
if (b === 0) return callback(new Error('Divide by zero'))
callback(null, a / b)
})
}
divide(10, 2, (err, result) => {
if (err) return console.error(err.message)
console.log(result) // 5
})Running callbacks in sequence
When one async step depends on the previous result, callbacks force nesting — each step inside the prior callback:
getUser(1, (err, user) => {
if (err) return done(err)
getPosts(user.id, (err, posts) => {
if (err) return done(err)
getComments(posts[0].id, (err, comments) => {
if (err) return done(err)
done(null, comments) // the "pyramid of doom" begins…
})
})
})Running callbacks in parallel
When steps are independent, fire them at once and count completions — no waiting in series:
function loadAll(ids, done) {
const results = []
let remaining = ids.length
let failed = false
ids.forEach((id, i) => {
getUser(id, (err, user) => {
if (failed) return
if (err) { failed = true; return done(err) }
results[i] = user // keep original order
if (--remaining === 0) done(null, results)
})
})
}This manual bookkeeping is exactly what Promise.all does for you — one reason promises won out.