Course:Node.js & Express/
Lesson

When Node.js was first released, callbacks were the only way to handle asynchronous work. You handed a function to another function and trusted it would be called when the job was done. This pattern is still everywhere, in timers, event listeners, and older library code, so understanding it is non-negotiable even if you write async/await every day.

What a callbackWhat is callback?A function you pass into another function to be called later, often when an operation finishes or an event occurs. actually is

A callback is just a function you pass as an argument. The receiving function decides when to call it, usually after some I/O operation completes. There is nothing magical here, it is ordinary JavaScript.

// fs.readFile is asynchronous; it calls your function when done
import { readFile } from 'fs';

readFile('./data.txt', 'utf-8', (err, data) => {
  if (err) {
    console.error('Could not read file:', err.message);
    return;
  }
  console.log('File contents:', data);
});

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

Notice that the last console.log runs first. Node.js does not block while waiting for the disk, it registers the callback and moves on.

02

The error-first convention

Every Node.js core callbackWhat is callback?A function you pass into another function to be called later, often when an operation finishes or an event occurs. follows the same contract: the first argument is an error, and all subsequent arguments are the result. If there is no error, the first argument is null.

PositionValue when successValue when failure
arg[0]nullError object
arg[1]result dataundefined
arg[2+]more dataundefined
function myCallback(error, result) {
  if (error) {
    // Always handle the error first
    console.error('Something went wrong:', error.message);
    return; // Stop here - result is useless
  }
  // Safe to use result now
  console.log('Got result:', result);
}
Why always check the error first? If you skip the error check and the operation failed, result is undefined. Accessing properties on it throws a TypeError that is much harder to debug than the original error.
03

CallbackWhat is callback?A function you pass into another function to be called later, often when an operation finishes or an event occurs. hell: the pyramid of doom

The problem shows up the moment you need to chain async operations. Each step depends on the previous result, so you nest one callback inside another, which creates the infamous "pyramid of doom."

// Reading a config, connecting to DB, querying users, writing output
// Each step is nested inside the previous one's callback
readFile('config.json', 'utf8', (err, config) => {
  if (err) { console.error(err); return; }

  parseConfig(config, (err, parsed) => {
    if (err) { console.error(err); return; }

    connectDB(parsed.dbUrl, (err, db) => {
      if (err) { console.error(err); return; }

      db.query('SELECT * FROM users', (err, users) => {
        if (err) { console.error(err); return; }

        writeFile('output.json', JSON.stringify(users), (err) => {
          if (err) { console.error(err); return; }
          console.log('Done!');
        });
      });
    });
  });
});

The indentation alone tells the story. This code grows to the right with every new step, error handling is copy-pasted five times, and adding one more step means wrapping everything again.

04

Escaping the pyramid with named functions

The simplest fix is to pull each callbackWhat is callback?A function you pass into another function to be called later, often when an operation finishes or an event occurs. out into a named top-level function. The execution order is identical but the visual nesting disappears.

readFile('config.json', 'utf8', handleConfig);

function handleConfig(err, config) {
  if (err) return console.error(err);
  parseConfig(config, handleParsed);
}

function handleParsed(err, parsed) {
  if (err) return console.error(err);
  connectDB(parsed.dbUrl, handleDB);
}

function handleDB(err, db) {
  if (err) return console.error(err);
  db.query('SELECT * FROM users', handleUsers);
}

function handleUsers(err, users) {
  if (err) return console.error(err);
  writeFile('output.json', JSON.stringify(users), handleWrite);
}

function handleWrite(err) {
  if (err) return console.error(err);
  console.log('Done!');
}

This is a legitimate pattern, but it still requires careful naming and the error repetition stays. Promises and async/await solve that more elegantly, you will cover those in the next lesson.

05

Converting callbacks to promises

When you encounter a library that only offers a callbackWhat is callback?A function you pass into another function to be called later, often when an operation finishes or an event occurs. APIWhat is api?A set of rules that lets one program talk to another, usually over the internet, by sending requests and getting responses., util.promisify wraps it for you automatically. It works with any function that follows the error-first convention.

import { promisify } from 'util';
import { readFile } from 'fs'; // Callback version

const readFileAsync = promisify(readFile);

// Now you can use it with async/await
async function loadConfig() {
  try {
    const data = await readFileAsync('./config.json', 'utf8');
    return JSON.parse(data);
  } catch (err) {
    console.error('Failed to load config:', err.message);
    return null;
  }
}
06

Quick reference

PatternSyntaxWhen to use
Inline callbackfn(arg, (err, res) => {})Simple one-off operations
Named callbackfn(arg, handler) function handler(err, res) {}Multi-step chains
util.promisifyconst fn = promisify(originalFn)Third-party callback APIs
Modern fs/promisesimport { readFile } from 'fs/promises'All new file system code
setTimeout / setIntervalAlways callback-basedTimers (no promise version)