NodeJSThe Callback Pattern

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:

JS
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 (err)

Error object on failure, null/undefined on success

2nd+ args

The result(s) — only meaningful when err is null

Position

The callback is the last argument to the function

Always `return` after handling the error
A frequent bug: checking `if (err)` but forgetting to `return` — so execution falls through and the success code runs with undefined data. Write `if (err) return handle(err)`. The early return is what keeps the two paths mutually exclusive.
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:

JS
// 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 later
Writing your own error-first function

Follow the convention so your function composes with everything else (and can be promisify-ed later):

JS
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
})
Don't write a sometimes-sync, sometimes-async callback
A function that calls its callback *synchronously* in one branch and *asynchronously* in another is a notorious footgun ("releasing Zalgo") — callers can't reason about ordering. Make it consistently async by deferring the synchronous path with `process.nextTick` or `queueMicrotask`, as above. Pick one timing and stick to it.
Running callbacks in sequence

When one async step depends on the previous result, callbacks force nesting — each step inside the prior callback:

JS
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…
    })
  })
})
This nesting is the seed of callback hell
Each dependent step adds a layer of indentation and another repeated `if (err) return`. Three levels is uncomfortable; ten is unreadable. The structural problems — and the modern escapes (promises, `async/await`) — are the subject of [Callback Hell & How to Avoid It](/nodejs/callback-hell).
Running callbacks in parallel

When steps are independent, fire them at once and count completions — no waiting in series:

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

Callbacks aren't always async
`array.map(fn)` uses a callback too — synchronously
"Callback" just means "a function passed to another function". `[1,2,3].map(x => x * 2)` passes a callback that runs *synchronously*. The error-first, deferred convention applies specifically to Node's *asynchronous* callbacks — don't assume every callback is async.
Next
See how deep nesting becomes unmaintainable, and the four ways out: [Callback Hell & How to Avoid It](/nodejs/callback-hell).