JavaScript Callbacks

5 minute read

A callback is a function you pass to another function, to be invoked later. It’s the oldest async pattern in JavaScript and still pervasive — every setTimeout, every addEventListener, every array .map(fn) takes a callback. Understanding them is non-negotiable, even though we now reach for promises and async/await for new async work.

Synchronous Callbacks

The simplest form — the callback runs before the outer function returns.

[1, 2, 3].forEach(n => console.log(n));   // arrow is the callback

["c", "a", "b"].sort((a, b) => a.localeCompare(b));

const evens = [1, 2, 3, 4].filter(n => n % 2 === 0);

The array methods from chapter 19 (map, filter, reduce, find, …) all take synchronous callbacks. You don’t usually call them “callbacks” — but technically that’s what they are.

Asynchronous Callbacks

The classic pattern: kick off something slow, and let it call you back when done.

setTimeout(() => {
  console.log("ran after 1s");
}, 1000);

document.querySelector("#btn").addEventListener("click", e => {
  console.log("clicked");
});

// Node.js — read a file
import { readFile } from "node:fs";

readFile("./data.json", "utf8", (err, text) => {
  if (err) return console.error(err);
  console.log(text);
});

The Node-Style (err, result) Convention

In Node and many older browser libs, async callbacks follow error-first convention:

function asyncWork(input, callback) {
  setTimeout(() => {
    if (!input) return callback(new Error("input required"));
    callback(null, input.toUpperCase());
  }, 100);
}

asyncWork("hello", (err, result) => {
  if (err) {
    console.error(err);
    return;
  }
  console.log(result);   // "HELLO"
});

Two rules:

  • First arg is err (null on success)
  • Subsequent args are the result(s)

This convention lets every callback share a uniform error-handling shape.

Writing a Function That Takes a Callback

function fetchUser(id, callback) {
  setTimeout(() => {
    if (id < 0) return callback(new Error("Invalid id"));
    callback(null, { id, name: "Ada" });
  }, 200);
}

fetchUser(42, (err, user) => {
  if (err) return console.error(err);
  console.log(user);
});

Rules of thumb:

  1. Call the callback exactly once. Never zero (silent hang), never twice (double-firing handlers).
  2. Call it asynchronously, even on the synchronous path — use queueMicrotask(...) or setTimeout(..., 0) — to avoid surprise sync execution that breaks caller assumptions.
// ❌ Sometimes sync, sometimes async — caller can't tell
function flakyAsync(value, cb) {
  if (cache.has(value)) return cb(null, cache.get(value));   // sync
  setTimeout(() => cb(null, compute(value)), 100);           // async
}

// ✅ Always async
function consistentAsync(value, cb) {
  if (cache.has(value)) {
    queueMicrotask(() => cb(null, cache.get(value)));
    return;
  }
  setTimeout(() => cb(null, compute(value)), 100);
}

Callback Hell

When you need to chain several async steps, naive nested callbacks become a sideways pyramid:

loadUser(1, (err, user) => {
  if (err) return console.error(err);

  loadOrders(user.id, (err, orders) => {
    if (err) return console.error(err);

    loadItems(orders[0].id, (err, items) => {
      if (err) return console.error(err);

      computeTotal(items, (err, total) => {
        if (err) return console.error(err);

        console.log("total", total);
      });
    });
  });
});

Three problems:

  1. Visual indent — keeps growing
  2. Error handling — repeated five times
  3. Sequencing complex flows — branching, retries, parallel becomes painful

This is the historical pressure that produced Promise and then async/await.

Mitigating Callback Hell

Even with callbacks you can flatten by naming each step:

function start() {
  loadUser(1, onUser);
}

function onUser(err, user) {
  if (err) return done(err);
  loadOrders(user.id, onOrders);
}

function onOrders(err, orders) {
  if (err) return done(err);
  loadItems(orders[0].id, onItems);
}

function onItems(err, items) {
  if (err) return done(err);
  computeTotal(items, done);
}

function done(err, total) {
  if (err) return console.error(err);
  console.log("total", total);
}

start();

Flatter, but the error handling is still scattered. Promises handle it once at the chain’s end.

Promisifying Callback APIs

Wrap a callback-style function to return a promise:

function promisify(fn) {
  return (...args) =>
    new Promise((resolve, reject) => {
      fn(...args, (err, result) => {
        if (err) return reject(err);
        resolve(result);
      });
    });
}

const fetchUserP = promisify(fetchUser);

const user = await fetchUserP(42);
console.log(user);

Node ships this as util.promisify; most modern Node APIs also offer promise variants in their node:fs/promises / node:dns/promises modules.

Common Callback Patterns You Still See

// Event handler
btn.addEventListener("click", onClick);

// Timer
setTimeout(callback, 1000);
setInterval(callback, 1000);

// Array helpers
[1, 2, 3].map(fn).filter(fn).reduce(fn, init);

// Sort comparator
items.sort((a, b) => a.id - b.id);

// requestAnimationFrame
requestAnimationFrame(function tick(t) {
  // …
  requestAnimationFrame(tick);
});

// Observer
const obs = new MutationObserver(records => { /* … */ });

// Iterator-style callbacks (Node streams)
stream.on("data", chunk => { /* … */ });
stream.on("end",  () => { /* … */ });
stream.on("error", err => { /* … */ });

A Realistic Example — Retry with Backoff (Callback Style)

Most modern code would use promises, but seeing the same logic in callback form makes the comparison clear:

function retry(fn, { attempts = 3, baseDelayMs = 200 } = {}, callback) {
  let attempt = 0;

  function tryOnce() {
    fn((err, result) => {
      if (!err) return callback(null, result);
      attempt++;
      if (attempt >= attempts) return callback(err);
      const delay = baseDelayMs * 2 ** (attempt - 1);
      setTimeout(tryOnce, delay);
    });
  }

  tryOnce();
}

retry(cb => loadConfig(cb), { attempts: 5 }, (err, config) => {
  if (err) return console.error("gave up", err);
  console.log("got", config);
});

Compare with the promise version (next chapter) — same logic, half the brain power to read.

Common Mistakes

// ❌ Calling the callback twice
function f(cb) {
  doWork((err, result) => {
    cb(err, result);
    if (extraCheck()) cb(null, "extra");   // ✗ second call
  });
}

// ❌ Forgetting to handle the error path
loadThing((err, result) => {
  use(result);    // ignores err — silently broken
});

// ❌ Synchronous "callback" that throws — caller can't recover normally
function flaky(cb) {
  try {
    cb(null, JSON.parse(somethingBad));   // throw bubbles into caller's stack
  } catch (e) { cb(e); }
}

// ❌ Using `this` inside a callback
const obj = {
  name: "Ada",
  greet() {
    setTimeout(function () {
      console.log(this.name);   // undefined — `this` lost
    }, 100);
  },
};

// ✅ Arrow keeps outer this
const obj = {
  name: "Ada",
  greet() {
    setTimeout(() => console.log(this.name), 100);
  },
};

Best Practices Checklist

  • ✅ Use error-first (err, result) convention for new callback APIs
  • ✅ Call the callback exactly once, always asynchronously
  • ✅ Promisify callback APIs at the boundary so the rest of your code is async/await
  • ✅ Use arrows for callbacks that need outer this
  • ✅ Name each step in chains; flatten visually
  • ❌ Don’t nest more than two callbacks deep — promisify or refactor
  • ❌ Don’t write functions that are sync sometimes and async other times
  • ❌ Don’t swallow errors silently

What’s Next?

You understand the callback world and its pain points. The fix arrived in ES2015: promises — values that represent “a result that isn’t ready yet”. Next chapter shows how they tame async flow.

← Previous: JS Event Loop Next: JS Promises →

Updated: