Last updated: 2026-09-14

Asynchronous Programming — Callbacks

Asynchronous programming is not optional in Node.js. The moment you read a file, query a database, or make an HTTP request, you are doing something that takes time — and Node.js will not sit idle while it waits. It moves on and comes back when the result is ready. The mechanism for "coming back" is the callback.

Callbacks are the oldest and most fundamental async pattern in Node.js. Every other async pattern — Promises, async/await — is built on top of them. Understanding callbacks is not just historical context; it is the foundation beneath everything that came after.


What Is a Callback?

A callback is a function passed as an argument to another function, to be called at a later point in time. The idea is not unique to asynchronous programming — callbacks are used synchronously too — but in the context of Node.js, "callback" almost always refers to a function that is called when an asynchronous operation completes.

function greet(name, callback) {
  const message = `Hello, ${name}`;
  callback(message);
}

greet('Wariz', (message) => {
  console.log(message); // Hello, Wariz
});

This is a synchronous callback — callback is called immediately inside greet. Asynchronous callbacks follow the same shape, but the call happens later, after an operation completes.


The Callback Pattern in Node.js

Node.js established a convention for asynchronous callbacks that is followed consistently across the entire core API and the wider ecosystem. It is called the error-first callback pattern, sometimes also called Node.js-style callbacks or errback pattern.

The convention is:

  1. The callback is always the last argument to the function
  2. The first argument to the callback is always an error — either an Error object if something went wrong, or null if the operation succeeded
  3. Subsequent arguments carry the result of the operation
fs.readFile('/path/to/file.txt', 'utf8', (err, data) => {
  if (err) {
    console.error('Something went wrong:', err.message);
    return;
  }
  console.log(data);
});

The err parameter must always be checked first. If err is not null, the operation failed and data should not be used — its value is undefined or meaningless in that case. If err is null, the operation succeeded and data contains the result.

This convention is simple but powerful. Because every asynchronous function in Node.js follows it, you always know what to expect from a callback's signature. There is no guessing whether the error is the first or second argument, and there is no need for separate success and failure handlers.


Reading a File with a Callback

The fs.readFile function is the canonical example of the Node.js callback pattern:

const fs = require('fs');

fs.readFile('data.txt', 'utf8', (err, data) => {
  if (err) {
    console.error('Failed to read file:', err.message);
    return;
  }
  console.log('File contents:', data);
});

console.log('This runs before the file is read');

Output:

This runs before the file is read
File contents: (contents of data.txt)

The readFile call returns immediately. Node.js hands the file read operation to libuv, which reads the file in the background. The next line — console.log('This runs before the file is read') — executes while the file is still being read. When the read completes, libuv places the callback in the event loop's poll queue, the event loop picks it up, and the callback runs.

This is the essence of non-blocking I/O expressed in code.


Writing Your Own Async Functions with Callbacks

When you write a function that performs asynchronous work, you follow the same error-first convention:

const fs = require('fs');

function readConfig(filepath, callback) {
  fs.readFile(filepath, 'utf8', (err, data) => {
    if (err) {
      callback(new Error(`Could not read config file: ${err.message}`));
      return;
    }

    let config;
    try {
      config = JSON.parse(data);
    } catch (parseErr) {
      callback(new Error(`Invalid JSON in config file: ${parseErr.message}`));
      return;
    }

    callback(null, config);
  });
}

readConfig('./config.json', (err, config) => {
  if (err) {
    console.error(err.message);
    return;
  }
  console.log('Config loaded:', config);
});

A few patterns to note here:

  • The function calls callback(error) when something goes wrong — never throwing, because a thrown error in an async context will not be caught by the caller
  • The function calls callback(null, result) when it succeeds — passing null explicitly as the first argument
  • After calling callback in an error path, the function returns immediately — this prevents the callback from being called a second time further down

A callback should be called exactly once. Calling it twice — or never — is a bug. Returning immediately after every error case is the discipline that prevents double calls.


Sequencing Asynchronous Operations

Where callbacks become genuinely difficult is when multiple asynchronous operations need to run in sequence — where each operation depends on the result of the previous one.

Suppose you need to: read a config file, use the config to connect to a database, then use that connection to query a user record.

fs.readFile('config.json', 'utf8', (err, data) => {
  if (err) return handleError(err);

  const config = JSON.parse(data);

  db.connect(config.connectionString, (err, connection) => {
    if (err) return handleError(err);

    connection.query('SELECT * FROM users WHERE id = ?', [userId], (err, rows) => {
      if (err) return handleError(err);

      connection.close((err) => {
        if (err) return handleError(err);

        console.log('User:', rows[0]);
      });
    });
  });
});

Each operation nests inside the callback of the previous one. This pattern — where asynchronous operations are chained through nesting — is called callback hell or the pyramid of doom, named for the triangular shape the code takes on as indentation grows with each level.

This is not merely an aesthetic problem. Deeply nested callbacks are:

  • Hard to read — the logic is buried under layers of indentation
  • Hard to maintain — adding, removing, or reordering a step requires restructuring multiple levels of nesting
  • Hard to handle errors in — each level requires its own error check, and a missed return after an error call results in the callback running twice
  • Hard to reason about — the flow of data and control is non-linear and difficult to follow

Callback Hell

The term "callback hell" refers specifically to deeply nested callbacks that make code difficult to follow and maintain. It is the defining limitation of the callback-based async pattern.

getData(function(a) {
  getMoreData(a, function(b) {
    getMoreData(b, function(c) {
      getMoreData(c, function(d) {
        getMoreData(d, function(e) {
          console.log(e);
        });
      });
    });
  });
});

The problem is not that callbacks are bad — it is that nesting callbacks to express sequential dependencies does not scale. The structure of the code no longer reflects the structure of the logic.

Mitigating Callback Hell

Several techniques reduce callback hell without abandoning callbacks entirely:

Named functions instead of anonymous functions. Rather than nesting anonymous functions, define each step as a named function and pass it by reference:

function onFileRead(err, data) {
  if (err) return handleError(err);
  const config = JSON.parse(data);
  db.connect(config.connectionString, onConnected);
}

function onConnected(err, connection) {
  if (err) return handleError(err);
  connection.query('SELECT * FROM users WHERE id = ?', [userId], onQuery);
}

function onQuery(err, rows) {
  if (err) return handleError(err);
  console.log('User:', rows[0]);
}

fs.readFile('config.json', 'utf8', onFileRead);

The nesting is gone. Each step is a flat function at the top level. The flow — onFileRead → onConnected → onQuery — is easy to follow. The tradeoff is that the functions are now separated from each other, making the sequence of operations less immediately apparent at a glance.

Modularisation. Break related async operations into separate functions that each handle one concern. Expose them with their own callback interfaces and compose them at the call site.

The async library. Before Promises, the npm package async was widely used to manage complex async control flows — running operations in series, in parallel, with concurrency limits, or with retry logic — without manual nesting. It is less common today but still used in codebases that predate Promises.


Parallel Execution with Callbacks

Callbacks are well-suited for firing multiple operations at the same time and waiting for all of them to complete. The pattern requires tracking how many operations are outstanding:

const fs = require('fs');

const files = ['a.txt', 'b.txt', 'c.txt'];
const results = [];
let completed = 0;

files.forEach((file, index) => {
  fs.readFile(file, 'utf8', (err, data) => {
    if (err) {
      console.error(`Failed to read ${file}:`, err.message);
      return;
    }

    results[index] = data;
    completed++;

    if (completed === files.length) {
      console.log('All files read:', results);
    }
  });
});

All three readFile calls are initiated immediately. They run concurrently — not in parallel on separate threads, but concurrently in the sense that all three are in-flight with libuv at the same time. The counter tracks when they have all completed. The results[index] = data line preserves the original order even if the files complete in a different sequence.

This pattern works, but it is fragile. Error handling is incomplete — if one file fails, completed never reaches files.length and the final callback never fires. Production-grade implementations need to handle partial failures explicitly, which adds significant complexity.

This is the kind of problem that Promises — and Promise.all specifically — were designed to solve cleanly.


Common Mistakes with Callbacks

Forgetting to return after calling the callback

function process(data, callback) {
  if (!data) {
    callback(new Error('No data provided'));
    // Missing return — execution continues
  }
  callback(null, transform(data)); // Called a second time
}

The fix is always to return after calling the callback in an error path:

function process(data, callback) {
  if (!data) {
    callback(new Error('No data provided'));
    return;
  }
  callback(null, transform(data));
}

Throwing inside an async callback

fs.readFile('data.txt', 'utf8', (err, data) => {
  if (err) throw err; // This will crash the process, not be caught by the caller
});

A throw inside an async callback is not catchable by the code that initiated the async operation. By the time the callback runs, the original call has long since returned. Thrown errors in async callbacks become uncaught exceptions and will crash the Node.js process unless caught by process.on('uncaughtException'). Always pass errors to the callback:

fs.readFile('data.txt', 'utf8', (err, data) => {
  if (err) {
    callback(err);
    return;
  }
  callback(null, data);
});

Making a function sometimes synchronous, sometimes asynchronous

function getUser(id, callback) {
  if (cache[id]) {
    callback(null, cache[id]); // Synchronous
    return;
  }
  db.query(id, callback); // Asynchronous
}

This is called a zalgo function — a function whose callback is sometimes called synchronously and sometimes asynchronously. Callers cannot reason about when their callback will fire, which leads to subtle ordering bugs.

The fix is to ensure the callback is always called asynchronously, even in the synchronous path:

function getUser(id, callback) {
  if (cache[id]) {
    process.nextTick(() => callback(null, cache[id]));
    return;
  }
  db.query(id, callback);
}

process.nextTick defers the callback to the next iteration of the event loop, making the behaviour consistent regardless of whether the cache is hit.


The Limits of Callbacks

Callbacks work. They are fast, they require no abstraction layer, and they are what every Node.js core API uses under the hood. The problems with them are not performance problems — they are readability and composability problems.

Sequential async operations become deeply nested. Error handling is repetitive and easy to get wrong. Composing multiple async operations — running some in series, some in parallel, handling partial failures — requires manual bookkeeping that is error-prone and hard to test.

These are the problems that Promises were designed to solve. Promises do not replace callbacks at the runtime level — the event loop still schedules callbacks. They provide an abstraction on top of callbacks that makes the code that sequences and composes async operations dramatically easier to read, write, and reason about.


Summary

ConceptWhat It Means
CallbackA function passed as an argument to be called when an operation completes
Error-first conventionCallback signature is always (err, result) — error is the first argument
Non-blockingThe function returns immediately; the callback fires later via the event loop
Callback hellDeeply nested callbacks caused by chaining sequential async operations
Named functionsA technique to flatten nested callbacks without changing their behaviour
Zalgo functionA function that calls its callback sometimes synchronously, sometimes asynchronously — always a bug
process.nextTickUsed to defer a synchronous callback to make it consistently asynchronous