Skip to main content

Command Palette

Search for a command to run...

Understanding Callbacks in JavaScript

Before Promises and Async/Await, there was the callback. Here is how treating functions as values unlocks asynchronous programming.

Published
6 min read
H
CS Graduate | Technical Writing | Software development | 20K+ impressions

If you want to understand how JavaScript actually works, you have to accept one fundamental premise: in JavaScript, a function is just a value.

It is not a special, rigid structure. It is a piece of data. Just like you can assign a string to a variable, push a number into an array, or pass a boolean into a function, you can do all of those exact same things with a function.

Once you wrap your head around that, the concept of a callback stops being a confusing piece of jargon and becomes an incredibly obvious tool.

A callback is exactly what it sounds like: a function that you pass into another function as an argument, with the expectation that it will be called (executed) at a later time. Today, I want to look at why this pattern exists, how it powers the web, and why it ultimately forced JavaScript to evolve.


The Anatomy of a Callback

Let’s start synchronously. We won't worry about network requests or timers yet. Let’s just look at the raw mechanic of passing a function into another function.

Imagine we are writing a function that processes an array of numbers. We want it to be flexible—maybe we want to double the numbers, maybe we want to square them. Instead of hardcoding the math inside the processor, we can let the caller define the math by passing in a callback function.

// This is our callback function. It just knows how to double a number.
function double(n) {
    return n * 2;
}

// This function takes an array, and a callback function to apply to each item.
function processNumbers(numbers, mathCallback) {
    const result =[];
    for (let i = 0; i < numbers.length; i++) {
        // We execute the callback right here, passing the current number
        result.push(mathCallback(numbers[i]));
    }
    return result;
}

const myNumbers = [1, 2, 3];
const doubledNumbers = processNumbers(myNumbers, double);

console.log(doubledNumbers); // [2, 4, 6]

Mental Diagram: The Synchronous Flow

  1. processNumbers starts running.

  2. It hits the loop and calls mathCallback (which is pointing to our double function).

  3. The execution jumps to double, does the math, and returns the value.

  4. processNumbers catches the value, pushes it to the array, and continues the loop.

Notice that we passed double into processNumbers without parentheses. We didn’t write processNumbers(myNumbers, double()). If we added parentheses, we would be passing the result of the function. By omitting them, we pass the function itself.

If this looks familiar, it’s because this is exactly how JavaScript's built-in array methods work. When you write [1, 2, 3].map(n => n * 2), you are creating an anonymous arrow function and passing it to .map() as a callback.


The Real "Why": Asynchronous JavaScript

Synchronous callbacks are useful for code reuse, but they aren't the reason the callback pattern became the backbone of JavaScript. The real reason is asynchrony.

JavaScript is single-threaded. It has one call stack. It can only do one single thing at a time. If you tell JavaScript to fetch a massive file from a database, and it stops to wait for that file, the entire browser tab freezes. Users can't click buttons, animations stop, and the web page feels dead.

To prevent this, JavaScript offloads time-consuming tasks (like network requests or timers) to the browser. But JavaScript still needs a way to know when that task is finished.

Enter the callback. We hand the browser a task, and we say: "Hey, I’m going to keep running the rest of my code. When you finish downloading that file, call this function and pass the file into it."

The simplest example of this is setTimeout.

console.log("1. Starting the script");

setTimeout(() => {
    console.log("2. The timer is done!");
}, 2000);

console.log("3. Continuing with other work...");

If you run this, the output is: 1. Starting the script 3. Continuing with other work... (two seconds pass) 2. The timer is done!

The arrow function passed into setTimeout is a callback. We handed it off, JavaScript moved on, and two seconds later, the event loop pushed our callback back onto the stack to be executed.

Callbacks in the Wild

You write callbacks every day, often without thinking about the underlying architecture.

Event Listeners: When you click a button, you don't know when the user is going to click it. So you provide a callback.

const button = document.querySelector('#submit');
button.addEventListener('click', function(event) {
    // This callback runs only when the click happens
    console.log('Button clicked!');
});

Node.js File System: Before Promises were standardized, Node.js relied entirely on "error-first callbacks". You would ask the file system to read a file, and provide a function to handle the result or the error.

const fs = require('fs');

fs.readFile('user.json', 'utf8', (error, data) => {
    if (error) {
        console.error("Failed to read file:", error);
        return;
    }
    console.log("File contents:", data);
});

The Breaking Point: Callback Nesting

Callbacks are simple and elegant for single, isolated tasks. But software is rarely isolated. Usually, one asynchronous action depends on the result of another.

Imagine a scenario where we need to:

  1. Fetch a user from a database.

  2. Using that user's ID, fetch their recent posts.

  3. Using the ID of their first post, fetch the comments on that post.

If we use a traditional callback approach, the code ends up looking like this:

getUser('alice', function(userError, user) {
    if (userError) return handleError(userError);

    getPosts(user.id, function(postsError, posts) {
        if (postsError) return handleError(postsError);

        getComments(posts[0].id, function(commentsError, comments) {
            if (commentsError) return handleError(commentsError);

            console.log("Finally got the comments:", comments);
        });
    });
});

Mental Diagram: Nested Execution (Callback Hell)

  1. getUser fires... waits... ↳ getPosts fires... waits... ↳ getComments fires... waits... ↳ Execute final logic.

This triangular shape of code moving further and further to the right is notoriously known as Callback Hell or the Pyramid of Doom.

The problem here isn't just visual. The real issue is inversion of control. Every time you pass a callback to a function, you are giving away control of your program. You are trusting that getPosts will call your function exactly once, with the right arguments, and handle errors correctly. When you nest this deep, tracing the state of your application—or figuring out where a silent failure occurred—becomes an absolute nightmare.

The Takeaway

The nesting problem was severe enough that it drove the JavaScript community to invent Promises, and eventually the async/await syntax we use today to make asynchronous code read like synchronous code.

But under the hood? It’s still callbacks. Promises.then() takes a callback. The event loop still operates by queueing up functions to be executed when the call stack is clear.

You don’t need to write deeply nested callbacks anymore, but understanding why they exist gives you x-ray vision into JavaScript. When you realize that functions are just data that can be carried around and triggered later, the entire event-driven architecture of the web suddenly makes perfect sense.