Introduction: callbacks

A lot of the functions your JavaScript environment hands you can schedule asynchronous work: something you kick off now that completes at some later moment, without blocking the lines that follow.

The classic first example is setTimeout — you ask for code to run after a delay, and execution moves on immediately. But timers are just the tiniest slice. Loading scripts, fetching data over the network, reading a file — all of these start now and finish later.

Let’s build up a concrete case. Here’s a loadWidget(src) function that pulls in an external script by URL:

function loadWidget(src) {
  // build a <script> tag and attach it to the page
  // the browser then fetches src and runs it once the download completes
  let script = document.createElement('script');
  script.src = src;
  document.head.append(script);
}

It creates a fresh <script src="…"> element and drops it into the document <head>. The moment that element lands in the page, the browser starts downloading the file, and it runs the code inside as soon as the download is done.

Calling it is short:

// begin loading and running the script at this path
loadWidget('/widgets/clock.js');

The word to hold onto is asynchronous. The download starts right away, but the running of the script happens later — after loadWidget itself has already returned and moved on.

Anything you write below the call runs immediately. It does not pause and wait for the network:

loadWidget('/widgets/clock.js');
// these lines run right away
// they do NOT wait for the script to finish downloading
// ...
loadWidget(src)
→ returns immediately
MAIN THREAD (now)
next line
next line
BACKGROUND (later)
downloading…
then runs the script
loadWidget returns instantly; the script keeps loading in the background while later lines already run.

Now suppose we need to use whatever the new script defines — say it declares a mountWidget, and we want to call it. Doing that on the very next line falls flat:

loadWidget('/widgets/clock.js'); // the script contains "function mountWidget() {…}"

mountWidget(); // Error: mountWidget is not defined!

The browser almost certainly hasn’t finished downloading yet, so mountWidget doesn’t exist at that instant. As written, loadWidget gives us no way to know when the load is done. It fires the request and forgets. We need a hook for “run this the moment it’s ready.”

Callback in, notify out

The fix is to pass loadWidget a second argument: a function to call once the load completes. It’s called a callback — you hand a function to another function so it can call you back later.

function loadWidget(src, callback) {
  let script = document.createElement('script');
  script.src = src;

  script.onload = () => callback(script);

  document.head.append(script);
}

The onload handler fires after the script has downloaded and run. (You’ll see it again in Resource loading: onload and onerror.) When that happens, we invoke callback, passing along the script element so the caller has a reference to it.

Now the code that depends on the freshly loaded script goes inside the callback, where it’s guaranteed to run at the right time:

loadWidget('/widgets/clock.js', function() {
  // this runs only after the script has loaded
  mountWidget(); // works now
  // ...
});

That’s the whole shape of it: the second argument is a function — often anonymous — that runs when the async work is finished.

SYNCHRONOUS
let x = compute();
// x is ready on the next line
use(x);
ASYNCHRONOUS (callback)
compute(result => {
// runs later
use(result);
});
Ordinary calls return a value inline. A callback-style call schedules your function to run when the work completes.

Here’s a version you can actually run against a real script on a CDN:

function loadWidget(src, callback) {
  let script = document.createElement('script');
  script.src = src;
  script.onload = () => callback(script);
  document.head.append(script);
}

loadWidget('https://cdnjs.cloudflare.com/ajax/libs/dayjs/1.11.10/dayjs.min.js', script => {
  alert(`Nice, ${script.src} finished loading`);
  alert( dayjs ); // dayjs is a function defined by the loaded library
});

This pattern is callback-based asynchronous programming. A function that does its real work asynchronously accepts a callback argument, and you drop in the code to run afterward. We did it for loadWidget, but the idea is general — it shows up all over older APIs.

A callback inside a callback

What if you need two scripts, one after the other — load the first, and only once it’s ready, load the second?

The obvious move is to put the second loadWidget call inside the first one’s callback:

loadWidget('/widgets/clock.js', function(script) {

  alert(`Nice, ${script.src} is ready — now the next one`);

  loadWidget('/widgets/weather.js', function(script) {
    alert(`And the second widget is ready too`);
  });

});

Once the outer load finishes, its callback fires and starts the inner load. Sequence preserved.

Need a third? You nest again:

loadWidget('/widgets/clock.js', function(script) {

  loadWidget('/widgets/weather.js', function(script) {

    loadWidget('/widgets/map.js', function(script) {
      // ...continue after all three are loaded
    });

  });

});

Each new step lives one level deeper inside the previous callback. Fine for two or three actions. Start chaining more, though, and the shape becomes a problem — we’ll get to that.

Handling errors

So far we’ve pretended loads always succeed. They don’t. A file might be missing, the network might drop, the server might 500. The callback needs a way to hear about failure.

Here’s loadWidget upgraded to report errors:

function loadWidget(src, callback) {
  let script = document.createElement('script');
  script.src = src;

  script.onload = () => callback(null, script);
  script.onerror = () => callback(new Error(`Widget load error for ${src}`));

  document.head.append(script);
}

On success it calls callback(null, script) — no error, here’s your result. On failure it calls callback(error) — here’s what went wrong. The caller then branches on the first argument:

loadWidget('/widgets/clock.js', function(error, script) {
  if (error) {
    // something failed — handle it
  } else {
    // script is loaded and ready
  }
});

This is the error-first callback convention, and it’s everywhere in Node.js-style code. The rules are simple:

  1. The first parameter of the callback is reserved for an error. If something went wrong, the function calls callback(err).
  2. The rest of the parameters carry the successful result(s). On success it calls callback(null, result1, result2…)null in the error slot signals “all good.”

One callback, two jobs: report the failure or deliver the result.

callback(error, result)
error is setnull result
callback(new Error(…))
error is nullreal result
callback(null, script)
Error-first callback: the first argument tells you which path you're on.

Pyramid of Doom

For one or two steps, callbacks read fine. The trouble starts when several asynchronous actions must run in sequence, each depending on the last. Add error handling to every level and you get this:

loadWidget('clock.js', function(error, script) {

  if (error) {
    handleError(error);
  } else {
    // ...
    loadWidget('weather.js', function(error, script) {
      if (error) {
        handleError(error);
      } else {
        // ...
        loadWidget('map.js', function(error, script) {
          if (error) {
            handleError(error);
          } else {
            // ...continue after all scripts are loaded (*)
          }
        });

      }
    });
  }
});

Reading it top to bottom:

  1. Load clock.js; if there’s no error…
  2. load weather.js; if there’s no error…
  3. load map.js; if there’s no error, finally do the real work at (*).

Every added step pushes the code one level deeper and one level further right. Swap the ... placeholders for real logic — loops, conditionals, more async calls — and it gets genuinely hard to follow. This shape has a name: callback hell, or the pyramid of doom.

loadWidget(‘clock.js’, … {
loadWidget(‘weather.js’, … {
loadWidget(‘map.js’, … {
loadWidget(‘chat.js’, … {
// the tip of the pyramid
});
});
});
});
Each sequential async step nests inside the last, so the code marches down and to the right.

The nesting grows to the right with every new action, and it doesn’t take many before the indentation runs off the edge of the screen. As a way to structure code, it doesn’t scale.

One partial fix is to pull each step out into its own named, top-level function instead of an inline anonymous one:

loadWidget('clock.js', step1);

function step1(error, script) {
  if (error) {
    handleError(error);
  } else {
    // ...
    loadWidget('weather.js', step2);
  }
}

function step2(error, script) {
  if (error) {
    handleError(error);
  } else {
    // ...
    loadWidget('map.js', step3);
  }
}

function step3(error, script) {
  if (error) {
    handleError(error);
  } else {
    // ...continue after all scripts are loaded (*)
  }
}

Same behavior, no deep nesting — every action is its own flat function. The pyramid is gone because we broke the chain into named links.

But it trades one problem for another. The logic now reads like a torn-up spreadsheet: to follow the flow you have to jump from step1 to step2 to step3, hunting for the next piece. That eye-jumping is rough on anyone reading the code for the first time, since the order on the page no longer matches the order of execution.

On top of that, step1, step2, and step3 exist for one reason only — to dodge the pyramid. Nobody will ever call them from anywhere else. They’re single-use names cluttering the surrounding scope.

We’d like the readability of sequential code without the nesting or the scattered helpers. Promises are the standard answer, and everything that follows in this chapter builds on them.