NodeJSCallbacks Refresher

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:

JS
// 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

JS
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)
})
Why error-first?
An async error happens *later*, on a different tick, so a `try/catch` around the *call* cannot catch it — the call already returned successfully. Delivering the error as the first callback argument is how Node hands it back to you at the right moment. (Event-based APIs use a separate `'error'` event instead.)
Two rules that prevent the worst callback bugs
Call the callback exactly once, and never throw past it
Two failure modes haunt hand-written callback code: (1) calling the callback **twice** (e.g. forgetting to `return` after an error) corrupts downstream logic; (2) **throwing** synchronously inside an async callback escapes into the event loop and can crash the process, because there is no `try/catch` waiting for it. Always `return` after invoking the callback, and pass errors *into* it rather than throwing.

JS
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:

JS
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
    })
  })
})
The problems
Callback hell is hard to read, error handling is repetitive and easy to forget, you cannot use `try/catch`, and running steps in **parallel** requires fiddly manual counters. This pain is exactly why [Promises](/nodejs/promises-refresher) and [async/await](/nodejs/async-await-refresher) were created.
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:

JS
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')
The reverse: callbackify
Occasionally you must hand a promise-based function to an old callback-expecting API. `util.callbackify` does the inverse of `promisify`, converting an `async` function into an error-first one.
  • 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 err before the result, and return to avoid double-calling.

  • Never throw synchronously inside an async callback — pass the error instead.

  • Deep nesting → promisify and move to promises / async-await.

Next
See how promises flatten that pyramid in [Promises Refresher](/nodejs/promises-refresher).