DocsHub
Async JavaScript

Callbacks

Learn what callbacks are, how they work, and why they were the original solution for async JavaScript.

Callbacks

In the previous topic we learned that async operations do not block — they start, move on, and come back when done. But how does JavaScript know what to do when they finish?

The original answer was callbacks.

A callback is simply a function you pass to another function, to be called when something is done.

function greet(name, callback) {
  console.log(`Hello, ${name}!`);
  callback();
}

function sayBye() {
  console.log("Goodbye!");
}

greet("Ali", sayBye);
// Hello, Ali!
// Goodbye!

sayBye is a callback — passed to greet and called inside it. This is the core pattern. You hand a function to someone else and say "call this when you are done".


Callbacks Are Not Always Async

This is an important point. Callbacks are just a pattern — a function passed to another function. They are not automatically async. You have been using callbacks this entire course.

// forEach — synchronous callback
const numbers = [1, 2, 3];
numbers.forEach(n => console.log(n)); // callback runs synchronously

// setTimeout — asynchronous callback
setTimeout(() => {
  console.log("async callback");
}, 1000); // callback runs after 1 second

The difference is when the callback is called — immediately (sync) or later after some operation finishes (async).


Async Callbacks

When you pass a callback to an async operation, it gets called when that operation completes.

setTimeout and setInterval

console.log("Start");

setTimeout(() => {
  console.log("This runs after 2 seconds");
}, 2000);

console.log("End");

// Start
// End
// This runs after 2 seconds

The callback is not called immediately. It is handed to the browser, which calls it after 2 seconds. Meanwhile JavaScript keeps running — printing "End" before the timeout fires.

setInterval — repeat on a timer

let count = 0;

const timer = setInterval(() => {
  count++;
  console.log(`Tick: ${count}`);

  if (count === 5) {
    clearInterval(timer); // stop the interval
    console.log("Timer stopped.");
  }
}, 1000);

// Tick: 1
// Tick: 2
// Tick: 3
// Tick: 4
// Tick: 5
// Timer stopped.

setInterval calls the callback repeatedly every n milliseconds until clearInterval is called.


Callbacks for Custom Async Operations

You can design your own functions that accept callbacks — this is how async libraries and older APIs were built.

function fetchUser(userId, callback) {
  // Simulating a server request with setTimeout
  setTimeout(() => {
    const user = { id: userId, name: "Ali", email: "ali@example.com" };
    callback(null, user); // null means no error
  }, 1000);
}

fetchUser(1, (error, user) => {
  if (error) {
    console.log("Error:", error);
    return;
  }
  console.log("User:", user);
});

// (after 1 second)
// User: { id: 1, name: "Ali", email: "ali@example.com" }

Notice the pattern callback(error, result) — this is called the Node.js callback convention or error-first callback. The first argument is always the error (or null if no error), the second is the result. This became the standard way to write callbacks in Node.js.


Error Handling With Callbacks

function readFile(filename, callback) {
  setTimeout(() => {
    if (filename === "missing.txt") {
      callback(new Error("File not found"), null); // pass error
    } else {
      callback(null, "File contents here"); // pass result
    }
  }, 500);
}

readFile("data.txt", (error, content) => {
  if (error) {
    console.log("Something went wrong:", error.message);
    return;
  }
  console.log("Content:", content);
});
// Content: File contents here

readFile("missing.txt", (error, content) => {
  if (error) {
    console.log("Something went wrong:", error.message);
    return;
  }
  console.log("Content:", content);
});
// Something went wrong: File not found

Always check the error first. If there is an error — handle it and return. Only proceed to use the result when you know there was no error.


Callback Hell

Here is where callbacks show their biggest weakness. What if you need to do multiple async operations in sequence — each one depending on the result of the previous?

// Get user → get their posts → get comments on first post → get likes on first comment
fetchUser(1, (error, user) => {
  if (error) return console.log(error);

  fetchPosts(user.id, (error, posts) => {
    if (error) return console.log(error);

    fetchComments(posts[0].id, (error, comments) => {
      if (error) return console.log(error);

      fetchLikes(comments[0].id, (error, likes) => {
        if (error) return console.log(error);

        console.log(likes); // finally got the data
      });
    });
  });
});

This is callback hell — also called the pyramid of doom. The code keeps nesting deeper and deeper to the right. It is:

  • Hard to read
  • Hard to debug
  • Hard to maintain
  • Error handling is repeated at every level

And this is only four levels deep. Real applications can go much deeper.


Why Callbacks Were Not Enough

Callback Hell Problems Deep nestinghard to read Error handlingat every level Hard to runthings in parallel Hard to handlecancellation

Callbacks work for simple cases — a timer, a single event. But for complex async workflows they get unmanageable fast.

This is exactly why Promises were introduced in ES6 — to solve the problems callbacks created. But callbacks are still everywhere in older code and in Node.js APIs, so understanding them is essential.


Fixing Callback Hell — Named Functions

One way to make callback hell more readable is to use named functions instead of nesting anonymous ones:

// ❌ Nested anonymous callbacks — hard to read
fetchUser(1, (err, user) => {
  fetchPosts(user.id, (err, posts) => {
    fetchComments(posts[0].id, (err, comments) => {
      console.log(comments);
    });
  });
});

// ✅ Named functions — flat and readable
function handleComments(err, comments) {
  if (err) return console.log(err);
  console.log(comments);
}

function handlePosts(err, posts) {
  if (err) return console.log(err);
  fetchComments(posts[0].id, handleComments);
}

function handleUser(err, user) {
  if (err) return console.log(err);
  fetchPosts(user.id, handlePosts);
}

fetchUser(1, handleUser);

This is flat — no pyramid. But it is still callback-based and the logic is split across multiple functions which makes it harder to follow the flow. Promises solved this much more elegantly.


Callbacks Are Still Useful

Despite their problems with complex async code, callbacks are still the right tool in many situations:

// Event listeners — callbacks are perfect here
button.addEventListener("click", () => {
  console.log("Clicked!");
});

// Array methods — synchronous callbacks
const doubled = [1, 2, 3].map(n => n * 2);

// Simple timers
setTimeout(() => {
  notification.remove();
}, 3000);

// Simple one-off async operations
loadImage("photo.jpg", (image) => {
  document.body.appendChild(image);
});

For single async operations and synchronous iteration callbacks are clean and simple. It is only when you chain multiple async operations that they become painful.


A Real Example — Image Loader

function loadImage(src, onLoad, onError) {
  const img = document.createElement("img");

  img.addEventListener("load", () => {
    onLoad(img); // success callback
  });

  img.addEventListener("error", () => {
    onError(new Error(`Failed to load image: ${src}`)); // error callback
  });

  img.src = src;
}

loadImage(
  "https://example.com/photo.jpg",
  (img) => {
    img.style.width = "100%";
    document.body.appendChild(img);
    console.log("Image loaded successfully!");
  },
  (error) => {
    console.log("Image failed to load:", error.message);
  }
);

Two callbacks — one for success, one for error. Clean and readable for a single async operation. This is callbacks at their best.


Summary

  • A callback is a function passed to another function to be called when something is done
  • Callbacks can be synchronous (called immediately) or asynchronous (called later)
  • Use the error-first conventioncallback(error, result) — first argument is the error or null
  • Always check the error before using the result
  • Callback hell — deeply nested callbacks that become unreadable — is the main problem with this pattern
  • Use named functions to flatten callback hell when stuck with callbacks
  • Callbacks are still the right choice for event listeners, array methods, and simple one-off async operations
  • Promises were introduced to solve the problems of complex async callback chains — covered in the next topic

On this page