Callbacks and asynchronous code

Understand why JavaScript never waits, how callbacks deliver results later, and why nested callbacks led to promises.

  • Beginner
  • 8 min read
  • Updated 17 Oct 2026

What you will learn

  • Explain non-blocking code
  • Recognise callback nesting problems
  • Tell synchronous and asynchronous callbacks apart
  • Read error-first callbacks in Node.js

Before you start

Loading a file, waiting for a timer or fetching data from a server all take time. JavaScript does not stop and wait for them: it starts the work, carries on, and runs a function you supply when the result is ready. This lesson explains that model, the callbacks it was built on, and the problems that made the language add promises.

One thread, no waiting

JavaScript runs your code on a single thread. It can only do one thing at a time. If a line of code took three seconds, nothing else could happen for those three seconds: no clicks, no scrolling, no repainting.

You can see this by writing a deliberately slow loop:

JavaScript
function blockFor(ms) {
  const end = Date.now() + ms;
  while (Date.now() < end) {
    // busy-wait: do nothing, very fast, over and over
  }
}

console.log("Before");
blockFor(3000);
console.log("After"); // appears three seconds later

Run it in the browser console on any page and try to click or select text while it runs. The page freezes. That is blocking code.

Slow operations such as network requests and timers are handled differently. The browser (or Node.js) does the waiting outside your JavaScript, and your code keeps running. When the work finishes, a function you passed in is queued to run. That is non-blocking, or asynchronous, code.

JavaScript
console.log("1: start");

setTimeout(() => {
  console.log("3: timer finished");
}, 0);

console.log("2: end");

// → 1: start
// → 2: end
// → 3: timer finished

Even with a delay of 0, the timer's function runs last. setTimeout only schedules it. The function can run once the code that is running now has finished. Lesson 42 explains exactly how the engine decides that order with the event loop.

Callbacks

A callback is a function you pass to another function, which calls it for you. You have already used plenty:

JavaScript
const numbers = [3, 1, 2];

numbers.forEach((n) => console.log(n * 10));
// → 30
// → 10
// → 20

const button = document.querySelector("button");
button.addEventListener("click", () => console.log("clicked"));

The two callbacks above behave differently:

CallbackWhen it runs
forEach((n) => ...)Synchronously: immediately, before forEach returns
addEventListener("click", ...)Asynchronously: later, whenever a click happens

Both are callbacks. What makes code asynchronous is not the callback itself but when it is called.

Writing an asynchronous function with a callback

Before promises, asynchronous functions took a callback as their last argument. Here is a small function that pretends to load a course from a server, using setTimeout to simulate network delay:

JavaScript
const courses = [
  { id: 1, title: "JavaScript Basics", authorId: 7 },
  { id: 2, title: "CSS Grid in Practice", authorId: 9 },
];

function loadCourse(id, callback) {
  setTimeout(() => {
    const course = courses.find((c) => c.id === id);
    callback(course);
  }, 500);
}

loadCourse(1, (course) => {
  console.log(course.title); // → JavaScript Basics (after half a second)
});

console.log("Loading...");   // → Loading... (this logs first)

The result cannot be returned, because loadCourse has already returned by the time the course is found. It has to be passed forward into the callback.

Handling errors: the error-first convention

What if the course does not exist? A try...catch around the call cannot help:

JavaScript
try {
  loadCourse(99, (course) => {
    console.log(course.title); // throws a TypeError: course is undefined
  });
} catch (error) {
  console.log("Never reached");
}

By the time the callback runs, the try block has long finished. The error escapes and appears as an uncaught error in the console.

Node.js settled on a convention for this: the error-first callback. The first argument is an error, or null if all went well; the result comes second.

JavaScript
function loadCourse(id, callback) {
  setTimeout(() => {
    const course = courses.find((c) => c.id === id);
    if (!course) {
      callback(new Error(`No course with id ${id}`));
      return;
    }
    callback(null, course);
  }, 500);
}

loadCourse(99, (error, course) => {
  if (error) {
    console.error(error.message); // → No course with id 99
    return;
  }
  console.log(course.title);
});

You will still meet this style in older Node.js code. The built-in fs module's callback API uses it:

JavaScript
// read-config.mjs (run with: node read-config.mjs)
import { readFile } from "node:fs";

readFile("config.json", "utf8", (error, text) => {
  if (error) {
    console.error("Could not read config:", error.message);
    return;
  }
  console.log(text);
});

The .mjs extension tells Node.js the file is an ES module, so import works; lesson 34 covers modules. Modern Node.js code uses the promise version from node:fs/promises instead, which you will see in the next two lessons. Working with files on the server belongs to a Node.js course; here it only illustrates the pattern.

The problems with callbacks

Callbacks work, but they become painful as soon as one step depends on another.

Nesting: the pyramid of doom

Suppose you need to load a course, then its author, then the author's other courses. Each step needs the previous result, so each callback goes inside the last:

JavaScript
loadCourse(1, (error, course) => {
  if (error) return showError(error);

  loadAuthor(course.authorId, (error, author) => {
    if (error) return showError(error);

    loadCoursesBy(author.id, (error, otherCourses) => {
      if (error) return showError(error);

      render(course, author, otherCourses);
    });
  });
});

Three steps, three levels of indentation, three identical error checks. Real code with five or six steps, loops and conditions becomes very hard to read and change. This shape is often called callback hell or the pyramid of doom.

Error handling is easy to forget

Every callback must check error itself. Forget one check and a failure turns into a confusing TypeError further down, or into silence.

You hand over control

When you pass a callback to someone else's function, you trust it to call your callback:

  • exactly once, not twice and not never;
  • always asynchronously, or always synchronously, but not sometimes one and sometimes the other;
  • with the arguments in the documented order.

Nothing in the language enforces any of this. A buggy library that calls your payment callback twice charges the customer twice. This is sometimes called inversion of control: the flow of your program is in someone else's hands.

Running things in parallel is awkward

Loading three independent courses at once and waiting for all of them means writing your own counter:

JavaScript
const results = [];
let finished = 0;

[1, 2, 3].forEach((id, index) => {
  loadCourse(id, (error, course) => {
    results[index] = error ? null : course;
    finished += 1;
    if (finished === 3) console.log("All done", results);
  });
});

It works, but every project wrote its own version, each with its own bugs.

What came next

Promises, added to the language in ES2015, solve these problems. A promise is an object that stands for a future result. Instead of passing a callback in, the function hands a promise back, and you decide what to do with it. Promises:

  • can only settle once, so a callback cannot fire twice;
  • always run their handlers asynchronously;
  • pass errors down a chain to one handler;
  • come with built-in tools for running work in parallel.

Callbacks do not disappear. Event listeners, array methods and setTimeout all still take callbacks, and that is fine: they are the right tool for "run this every time something happens". Promises are for "give me this one result when it is ready".

Summary

  • JavaScript runs on one thread. Long-running code blocks the page.
  • Asynchronous operations hand the waiting to the browser or Node.js and call your function when they finish.
  • A callback is a function passed to another function. It can be called synchronously (forEach) or asynchronously (setTimeout, event listeners).
  • An asynchronous function cannot return its result; it passes the result to a callback.
  • try...catch cannot catch errors thrown later inside a callback. Node.js uses error-first callbacks instead.
  • Dependent steps nest into a pyramid, error checks are repeated, and you must trust the callee to call you correctly.
  • Promises, covered in the next lesson, were designed to fix exactly these problems.

Further reading