Callbacks in Asynchronous JavaScript

In the previous lesson, we learned how to split code across files with import and export. Now let’s start working with asynchronous JavaScript, beginning with callbacks.

⏳ What Does “Asynchronous” Mean?

Most code runs synchronously, which means each line finishes before the next one starts. But some tasks do not finish right away. Loading data from a server, reading a file, or waiting for a timer all take time, and JavaScript does not stop and wait for them. These slow tasks are called asynchronous, because they finish later instead of immediately.

So you need a way to say, “run this code once the slow task is done.” That instruction is a callback: a function you hand to another function to be called back later.

Type Meaning Example
Synchronous Finishes right away, line by line console.log("Hi")
Asynchronous Finishes later, after some time setTimeout(...)

⏱️ setTimeout: The Simplest Async Example

The setTimeout function waits a set number of milliseconds, then runs a callback. It is the easiest way to see asynchronous behavior in action.

callbacks.js
console.log("First");
setTimeout(() => {
console.log("Second");
}, 1000);
console.log("Third");

Let’s walk through what JavaScript does with each line:

  • console.log("First") runs immediately and prints First.
  • setTimeout(...) hands its callback to a timer and moves on without waiting. The 1000 means the callback is scheduled to run after 1000 milliseconds.
  • console.log("Third") runs next and prints Third, because the synchronous lines do not wait for the timer.
  • Once the timer finishes, the callback runs and prints Second.

So the output is First, then Third, then Second. JavaScript reads the file top to bottom, but the callback inside setTimeout is set aside and runs only after the timer finishes.

A delay of 0 still waits

Even setTimeout(fn, 0) runs the callback after the current code finishes. The callback always waits its turn, no matter how small the delay.

📦 A Practical Example: Loading Data

Real apps load data from somewhere, and that takes time. Here is a simulated data load using setTimeout to stand in for a slow request. The callback runs once the data is ready.

callbacks.js
function loadUser(callback) {
console.log("Loading user...");
setTimeout(() => {
const user = { id: 1, name: "Alex" };
callback(user);
}, 1500);
}
loadUser((user) => {
console.log("Loaded:", user.name); // Loaded: Alex
});

Let’s trace how the data reaches the callback:

  • loadUser accepts a callback parameter, which is the function to run once the user is ready.
  • It prints Loading user... right away to show the work has started.
  • setTimeout waits 1500 milliseconds, then builds the user object and passes it into callback(user).
  • The call to loadUser hands in an arrow function, and that function receives the user and prints its name.

The loadUser function does not return the user directly, because the user is not ready yet. Instead it calls the callback with the data once the timer finishes, so any code that needs the user must live inside that callback.

🌋 The Problem: Callback Hell

A single callback is easy to read. The trouble starts when one async task depends on another, and that one depends on a third. Each step nests inside the callback before it, and the code drifts to the right in a shape often called the pyramid of doom.

callbacks.js
loadUser((user) => {
loadPosts(user.id, (posts) => {
loadComments(posts[0].id, (comments) => {
loadAuthors(comments[0].id, (authors) => {
console.log(authors); // buried deep inside
});
});
});
});

Each line waits on the one above it, which is what forces the deep nesting:

  • loadUser runs first and gives us a user.
  • Only once we have the user can we call loadPosts(user.id, ...), so it nests inside the first callback.
  • loadComments needs a post id, so it nests inside the posts callback.
  • loadAuthors needs a comment id, so it nests one level deeper still.
  • The final console.log(authors) ends up buried far to the right.

This nesting is called callback hell. It is hard to read, hard to handle errors in, and hard to change. The next lesson introduces Promises, which flatten this pyramid into a clean chain.

Callbacks are not bad

Callbacks themselves are fine, and you will use them everywhere. The problem is only the deep nesting. Promises and async/await keep callbacks readable.

⚠️ Common Mistakes to Avoid

Mistake Problem Solution
Assuming async code runs in order Code after setTimeout runs before the callback Put dependent code inside the callback
Deeply nested callbacks The pyramid becomes unreadable Use Promises or named functions
Ignoring errors in callbacks A failed task fails silently Pass an error argument and check it

The error-first pattern is a common convention: the callback takes an error as its first argument, and you check it before using the result.

callbacks.js
function loadData(callback) {
setTimeout(() => {
const error = null;
const data = { value: 42 };
callback(error, data);
}, 1000);
}
loadData((error, data) => {
if (error) {
console.log("Something went wrong:", error); // ✅ handle the error
return;
}
console.log("Got data:", data.value);
});

Let’s see how the error and the data flow through this callback:

  • loadData calls callback(error, data) with two arguments: an error first, then the result.
  • Here error is null, which means nothing went wrong, and data holds the value we want.
  • The callback checks if (error) first. When an error is present, it prints a message and uses return to stop before touching the data.
  • When there is no error, it falls through and prints data.value, which is 42.

🔧 Try It Yourself!

  1. Write three console.log lines with a setTimeout in the middle, and predict the order before running it.
  2. Create a loadUser function that takes a callback and calls it with a user object after a delay.
  3. Nest two callbacks so one async task runs after another finishes.
  4. Add an error-first callback that prints a message when an error is passed.

🧩 What You’ve Learned

  • ✅ Asynchronous tasks finish later, not immediately
  • ✅ A callback is a function passed in to run after an async task is done
  • setTimeout runs its callback after a delay, so synchronous code runs first
  • ✅ Deeply nested callbacks create callback hell, the pyramid of doom
  • ✅ The error-first pattern lets callbacks report problems

🚀 What’s Next?

Now that you understand callbacks, we will learn a cleaner way to handle async work. Let’s continue to Promises.

Share & Connect