Callbacks Refresher
A callback is simply a function you pass to another function to be called later — typically when an asynchronous operation finishes. Callbacks were Node's original async mechanism, and the entire core library was built on them. Even though you write async/await today, you will meet callbacks in older code, in event emitters, and in many APIs that never moved on — so they remain essential to read fluently.
Synchronous vs asynchronous callbacks
Not every callback is async. array.map(fn) calls fn synchronously, right now, before map returns. setTimeout(fn) and fs.readFile(path, fn) call fn asynchronously, on a later tick. Confusing the two leads to "why is my data undefined?" bugs:
// SYNC callback — runs immediately, in order
[1, 2, 3].forEach((n) => console.log(n)) // 1, 2, 3 right now
// ASYNC callback — scheduled for later
function fetchData(callback) {
setTimeout(() => callback('here is your data'), 1000)
}
fetchData((result) => console.log(result)) // ~1s later
console.log('this prints FIRST among the async pair')1 2 3 this prints FIRST among the async pair here is your data
The "FIRST" line beats the data because fetchData schedules its work and returns immediately — the callback fires after the synchronous code finishes and the timer elapses.
The Node "error-first" convention
Node's callback-based APIs follow a strict, universal convention: the first argument is an error (or null if none), and results follow. Always check the error first and return on failure:
error-first callback
import fs from 'node:fs'
fs.readFile('config.json', 'utf8', (err, data) => {
if (err) {
console.error('Failed to read file:', err.message)
return // stop here — data is undefined on error
}
console.log('File contents:', data)
})Two rules that prevent the worst callback bugs
function done(err, value) { /* ... */ }
doThing((err, value) => {
if (err) return done(err) // the 'return' stops a double-call
done(null, value)
})Callback hell
When operations depend on each other, nesting callbacks creates a hard-to-read "pyramid of doom" — and notice the error check repeated at every level:
getUser(id, (err, user) => {
if (err) return handle(err)
getOrders(user, (err, orders) => {
if (err) return handle(err)
getDetails(orders[0], (err, details) => {
if (err) return handle(err)
// ...deeper and deeper
})
})
})Converting callbacks to promises
Node provides util.promisify to wrap any error-first callback function into a promise-returning one. Many core modules also expose promise versions directly (e.g. fs/promises), which you should prefer when available:
import { promisify } from 'node:util'
import fs from 'node:fs'
// Option A: wrap an error-first function
const readFile = promisify(fs.readFile)
const data = await readFile('config.json', 'utf8')
// Option B (preferred): use the promise API directly
import { readFile as read } from 'node:fs/promises'
const data2 = await read('config.json', 'utf8')A callback is a function passed to run later (async) or now (sync) — know which.
Node uses the error-first signature:
(err, result) => {}.Always handle
errbefore the result, andreturnto avoid double-calling.Never throw synchronously inside an async callback — pass the error instead.
Deep nesting → promisify and move to promises / async-await.