Home/Blog/Callback Functions in JavaScript - Everything You Need to Know
Callback Functions in JavaScript - Everything You Need to Know
13 min readJul 16, 2026

Callback Functions in JavaScript - Everything You Need to Know

Callbacks are everywhere in JavaScript and most developers use them without fully understanding what they are. This covers everything from first principles - sync vs async callbacks, error-first pattern, callback hell, and how they connect to Promises and async/await.

163 views0 likes0 comments0 bookmarks

If you have been writing JavaScript for any amount of time, you have used callback functions. Maybe without even realizing it. That addEventListener you wrote, that forEach loop, that setTimeout - all of them use callbacks. They are everywhere in JavaScript.

But most developers learn just enough about callbacks to use them and move on. They do not really understand what a callback is at the language level, why JavaScript uses them so heavily, or how they connect to everything else in async JavaScript.

This blog covers all of it. What callbacks are, how they work, where they come from, their limitations, and how they evolved into the patterns we use today. If you have read the synchronous and asynchronous Node.js blog or the JavaScript event loop blog on codekk.dev, this blog fills in an important piece of that picture.


What a Callback Function Actually Is

A callback function is just a function that you pass as an argument to another function, to be called later.

That is the entire definition. There is nothing magical about it. It is just a function being passed around like any other value.

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

function processUser(name, callback) {
  console.log("Processing user...");
  callback(name); // calling the function that was passed in
}

processUser("Rahul", greet);

Output:

Code
Processing user...
Hello, Rahul

Here greet is the callback. We did not call greet() ourselves - we passed it to processUser and let processUser call it when it was ready.

This is possible because in JavaScript, functions are first-class citizens. This means functions can be:

  • Stored in variables

  • Passed as arguments to other functions

  • Returned from functions

  • Stored in arrays or objects

Js
// Storing a function in a variable
const sayHello = function(name) {
  console.log(`Hello, ${name}`);
};

// Storing functions in an object
const actions = {
  greet: function(name) { console.log(`Hi ${name}`); },
  farewell: function(name) { console.log(`Bye ${name}`); }
};

// Returning a function from a function
function multiplier(factor) {
  return function(number) {
    return number * factor;
  };
}

const double = multiplier(2);
console.log(double(5)); // 10
console.log(double(8)); // 16

This ability to treat functions as values is the foundation of callbacks. Without it, callbacks would not exist.

[IMAGE: Diagram showing a function being passed as an argument into another function, with an arrow showing the callback being called inside the receiving function. Label: "Function passed in → Called when ready"]


Two Ways to Write a Callback

You can pass a callback as a named function or as an anonymous function written inline. Both work the same way.

Named function as callback:

Js
function handleClick() {
  console.log("Button was clicked");
}

document.getElementById("btn").addEventListener("click", handleClick);

Anonymous function as callback:

Js
document.getElementById("btn").addEventListener("click", function() {
  console.log("Button was clicked");
});

Arrow function as callback (modern style):

Js
document.getElementById("btn").addEventListener("click", () => {
  console.log("Button was clicked");
});

All three do exactly the same thing. The named function approach is cleaner when the callback logic is complex or when you need to reuse it. The anonymous and arrow function approach is cleaner for short, one-time callbacks.


Synchronous Callbacks

Not all callbacks are async. Some callbacks run immediately, right inside the function they are passed to. These are called synchronous callbacks.

The built-in array methods are the most common examples:

Js
const numbers = [1, 2, 3, 4, 5];

// forEach - callback runs for each element, synchronously
numbers.forEach(function(num) {
  console.log(num);
});

console.log("This runs AFTER forEach finishes");

Output:

Code
1
2
3
4
5
This runs AFTER forEach finishes

The callback passed to forEach runs synchronously - one by one, in order, before the next line runs. Same with map, filter, reduce, and sort:

Js
// map - transforms each element using the callback
const doubled = numbers.map(num => num * 2);
console.log(doubled); // [2, 4, 6, 8, 10]

// filter - keeps elements where callback returns true
const evens = numbers.filter(num => num % 2 === 0);
console.log(evens); // [2, 4]

// reduce - accumulates a value using the callback
const sum = numbers.reduce((total, num) => total + num, 0);
console.log(sum); // 15

// sort - sorts based on callback comparison
const words = ["banana", "apple", "cherry"];
const sorted = words.sort((a, b) => a.localeCompare(b));
console.log(sorted); // ["apple", "banana", "cherry"]

These are all callbacks - functions passed as arguments to be called by the method. They just happen to run synchronously, one by one, right there in place.

[IMAGE: Diagram showing synchronous callback flow - main code runs line by line, forEach iterates and calls callback for each element in order, then continues to next line. All on the same call stack thread]


Asynchronous Callbacks

This is where callbacks become more interesting and where most of the confusion happens.

An asynchronous callback is a function that gets called later - after some async operation completes. The surrounding code does not wait for it. It moves on immediately.

Js
console.log("1 - Start");

setTimeout(function() {
  console.log("3 - Inside timeout");
}, 2000);

console.log("2 - End");

Output:

Code
1 - Start
2 - End
3 - Inside timeout  ← appears after 2 seconds

The callback inside setTimeout does not run immediately. It runs 2 seconds later, after the rest of the code has already finished. This is the async callback pattern.

If you read the JavaScript event loop blog, you already know why this happens - the setTimeout callback goes into the Callback Queue and only runs after the call stack is empty. This is exactly that mechanism at work.

Real world async callback - reading a file in Node.js:

Js
const fs = require("fs");

console.log("1 - Before reading file");

fs.readFile("users.txt", "utf8", function(err, data) {
  if (err) {
    console.error("Error:", err);
    return;
  }
  console.log("3 - File contents:", data);
});

console.log("2 - After calling readFile");

Output:

Code
1 - Before reading file
2 - After calling readFile
3 - File contents: (file data here)

readFile starts reading the file and immediately returns. The callback runs later when the file is ready. Your code keeps going in the meantime. This is exactly what we covered in the Node.js and Express blog - non-blocking I/O that keeps the server responsive.


Error-First Callbacks - The Node.js Convention

When you work with Node.js built-in modules, you'll notice all async callbacks follow a specific pattern - the first argument is always an error, and the second is the data.

Js
fs.readFile("data.txt", "utf8", function(err, data) {
  // err comes first, always
  if (err) {
    console.error("Something went wrong:", err.message);
    return; // stop here if there's an error
  }
  // only use data if there's no error
  console.log(data);
});

This is called the error-first callback pattern (sometimes called Node-style callbacks or errbacks). The convention is:

  • First argument: error object if something went wrong, null if everything is fine

  • Second argument onwards: the actual result data

Js
// Writing a function that follows the error-first pattern
function divideNumbers(a, b, callback) {
  if (b === 0) {
    callback(new Error("Cannot divide by zero"), null);
    return;
  }
  callback(null, a / b);
}

divideNumbers(10, 2, function(err, result) {
  if (err) {
    console.error("Error:", err.message);
    return;
  }
  console.log("Result:", result); // Result: 5
});

divideNumbers(10, 0, function(err, result) {
  if (err) {
    console.error("Error:", err.message); // Error: Cannot divide by zero
    return;
  }
  console.log("Result:", result);
});

Always check for the error first. Always. If you skip the error check and something went wrong, data will be undefined and your code will break in a confusing way further down.


Callback Hell - The Biggest Problem With Callbacks

Here is where callbacks start causing real pain.

Imagine you need to do several async operations in sequence - read a user from a file, then fetch their orders from a database, then send them an email. Each step depends on the result of the previous one.

With callbacks, this looks like:

Js
fs.readFile("user.txt", "utf8", function(err, userId) {
  if (err) return console.error(err);

  database.getUser(userId, function(err, user) {
    if (err) return console.error(err);

    database.getOrders(user.id, function(err, orders) {
      if (err) return console.error(err);

      emailService.sendSummary(user.email, orders, function(err, result) {
        if (err) return console.error(err);

        console.log("Email sent successfully:", result);
      });
    });
  });
});

This is callback hell - also called the pyramid of doom because of the shape the indentation makes. Each operation nests inside the previous one, pushing the code further and further to the right.

[IMAGE: Visual representation of callback hell - code shown as a pyramid shape with increasing indentation at each level, each level labeled with the operation (read file, get user, get orders, send email). Arrow pointing to the shape saying "Pyramid of Doom"]

The problems with this pattern:

Hard to read - you have to trace through layers of nesting to follow the logic. By the time you reach the actual work being done, you are four levels deep.

Hard to maintain - adding a new step means adding another level of nesting. Removing a step means carefully unwrapping a layer.

Error handling is repetitive - notice how if (err) return console.error(err) appears at every single level. If you want consistent error handling, you write the same thing over and over.

Hard to debug - stack traces in deeply nested callbacks are confusing to read.

This is exactly the problem that Promises were created to solve - which we covered in detail in the synchronous and asynchronous Node.js blog.


How to Avoid Callback Hell - Even With Callbacks

Before Promises existed, developers came up with patterns to manage callback hell. Even if you use Promises today, knowing these patterns is useful.

Named functions instead of anonymous ones:

Js
function handleUserId(err, userId) {
  if (err) return handleError(err);
  database.getUser(userId, handleUser);
}

function handleUser(err, user) {
  if (err) return handleError(err);
  database.getOrders(user.id, handleOrders.bind(null, user));
}

function handleOrders(user, err, orders) {
  if (err) return handleError(err);
  emailService.sendSummary(user.email, orders, handleEmailResult);
}

function handleEmailResult(err, result) {
  if (err) return handleError(err);
  console.log("Email sent:", result);
}

function handleError(err) {
  console.error("Error:", err.message);
}

// Now the main flow reads cleanly
fs.readFile("user.txt", "utf8", handleUserId);

The logic is the same but the nesting is gone. Each callback is a named function that clearly states what it handles. The main flow reads like a sequence of steps.

Modularizing into separate functions:

Js
function readUserId(callback) {
  fs.readFile("user.txt", "utf8", callback);
}

function fetchUser(userId, callback) {
  database.getUser(userId, callback);
}

function fetchOrders(userId, callback) {
  database.getOrders(userId, callback);
}

function sendEmail(email, orders, callback) {
  emailService.sendSummary(email, orders, callback);
}

Breaking each async operation into its own named function makes the code testable, reusable, and much easier to read.


Callbacks vs Promises vs Async/Await

Callbacks are not wrong or outdated. But it is worth understanding how they relate to the newer patterns.

Callbacks - the original async pattern. Work fine for simple cases. Become messy with sequential async operations.

Promises - wrap the callback pattern in a cleaner API. Instead of passing a callback into a function, the function returns a Promise object and you chain .then() on it. Flat instead of nested.

Async/Await - syntax built on top of Promises. Makes async code look synchronous and is the most readable of the three.

Here is the same operation written all three ways:

Js
// 1. Callback style
fs.readFile("user.txt", "utf8", function(err, data) {
  if (err) return console.error(err);
  console.log(data);
});

// 2. Promise style
fs.promises.readFile("user.txt", "utf8")
  .then(data => console.log(data))
  .catch(err => console.error(err));

// 3. Async/Await style
async function readUser() {
  try {
    const data = await fs.promises.readFile("user.txt", "utf8");
    console.log(data);
  } catch (err) {
    console.error(err);
  }
}

All three do exactly the same thing. Callbacks are not replaced by Promises - Promises are built on top of callbacks under the hood. When you call .then(callback), you are still passing a callback function. The API is just cleaner.

[IMAGE: Side by side comparison of the same async operation written in all three styles - callback (pyramid shape, nested), Promise (flat chain), async/await (looks synchronous). Arrows connecting them showing evolution]


Where You Still See Callbacks Today

Even though async/await is now the standard for most async code, callbacks are still used everywhere:

Event listeners - always use callbacks:

Js
button.addEventListener("click", (event) => {
  console.log("Clicked:", event.target);
});

window.addEventListener("resize", () => {
  console.log("Window resized");
});

Array methods - always use synchronous callbacks:

Js
const users = [
  { name: "Rahul", age: 25 },
  { name: "Priya", age: 22 },
  { name: "Arjun", age: 28 }
];

const adults = users.filter(user => user.age >= 18);
const names = users.map(user => user.name);
const oldest = users.reduce((max, user) => user.age > max.age ? user : max);

setTimeout and setInterval:

Js
setTimeout(() => console.log("Runs once after 1 second"), 1000);

const interval = setInterval(() => console.log("Runs every 2 seconds"), 2000);
setTimeout(() => clearInterval(interval), 10000); // stop after 10 seconds

Express middleware and route handlers - these are callbacks:

Js
// The function passed to app.get is a callback
app.get("/users", (req, res) => {
  res.json({ users: [] });
});

// Middleware is a callback with three parameters
app.use((req, res, next) => {
  console.log(`${req.method} ${req.url}`);
  next();
});

If you built anything with Express from our Node.js and Express blog, you have been writing callbacks the whole time - just without thinking of them that way.


Higher Order Functions - The Bigger Concept

When you understand callbacks, you also understand higher order functions - functions that take other functions as arguments or return functions.

forEach, map, filter, reduce, setTimeout, addEventListener - all of these are higher order functions. They accept a callback and do something with it.

You can write your own higher order functions too:

Js
// A higher order function that runs a callback a given number of times
function repeat(times, callback) {
  for (let i = 0; i < times; i++) {
    callback(i);
  }
}

repeat(3, (i) => console.log(`Running iteration ${i}`));
// Running iteration 0
// Running iteration 1
// Running iteration 2

// A higher order function that adds logging to any function
function withLogging(fn) {
  return function(...args) {
    console.log(`Calling function with args:`, args);
    const result = fn(...args);
    console.log(`Function returned:`, result);
    return result;
  };
}

function add(a, b) { return a + b; }

const loggedAdd = withLogging(add);
loggedAdd(3, 4);
// Calling function with args: [3, 4]
// Function returned: 7

This pattern - wrapping one function with another - is used everywhere in real codebases. Express middleware is this pattern. React's higher order components are this pattern. The withLogging example above is a simplified version of what logging libraries actually do.


Common Mistakes With Callbacks

Calling the callback instead of passing it:

Js
// Wrong - greet() is called immediately, its return value (undefined) is passed
processUser("Rahul", greet());

// Correct - greet function reference is passed, to be called later
processUser("Rahul", greet);

This is one of the most common beginner mistakes. Adding () calls the function right there. Without (), you are passing the function itself.

Calling the callback more than once:

Js
function doSomething(callback) {
  callback("first call");
  callback("second call"); // this is a bug - callback called twice
}

In most patterns, a callback should be called exactly once. Calling it multiple times causes unexpected behavior, especially in async code.

Forgetting to return after calling the callback in error cases:

Js
// Bug - both branches run
function process(data, callback) {
  if (!data) {
    callback(new Error("No data"));
    // forgot return - code continues below
  }
  callback(null, data.toUpperCase()); // crashes because data is null
}

// Correct
function process(data, callback) {
  if (!data) {
    callback(new Error("No data"));
    return; // stop here
  }
  callback(null, data.toUpperCase());
}

Always return after calling the callback in an error branch. Without it, the function keeps running and the callback might get called a second time.


The Mental Model Worth Keeping

A callback is just a function you hand to someone else and say "call this when you are ready." That is it.

JavaScript uses callbacks heavily because it is single threaded and needs a way to say "do this thing, and when it is done, run this code" - without blocking everything while waiting. Callbacks were the original answer to that problem.

They are not outdated. They are still the right tool for event listeners, array methods, and simple async operations. For complex sequences of async operations, Promises and async/await are cleaner - but they are built on the same callback concept underneath.

Once callbacks click properly, Promises make more sense. And once Promises make sense, async/await becomes obvious. They are not separate concepts - they are the same idea, getting cleaner with each version.

Comments

Sign in to join the conversation

Related Articles

More insights you might enjoy

SSR, CSR, SSG, and ISR in Next.js - What They Are and When to Use Each One

SSR, CSR, SSG, and ISR in Next.js - What They Are and When to Use Each One

Next.js gives you four ways to render pages and each solves a different problem. This guide explains all four with real code examples and a practical decision guide.

Sadie Sink·9 min
3171
HTTP Status Codes Explained - What They Actually Mean and Why Developers Should Know Them

HTTP Status Codes Explained - What They Actually Mean and Why Developers Should Know Them

Most developers know 200 and 404. But understanding the full range - and when to use them in your own APIs - makes you a noticeably better backend developer.

Sadie Sink·10 min
2831
Async/Await in JavaScript - How It Actually Works and Why It Exists
Backend

Async/Await in JavaScript - How It Actually Works and Why It Exists

How async/await works in JavaScript - async functions, await, error handling, parallel vs sequential execution, and the most common mistakes explained.

Sadie Sink·12 min
1891
JavaScript Closures - The Concept Interviewers Actually Test
JavaScript

JavaScript Closures - The Concept Interviewers Actually Test

Closures are one of the most asked-about JavaScript concepts in interviews, and also one of the most misunderstood. This blog explains what a closure actually is, why functions can remember variables from outside their own scope, and where closures show up in real code

Vishal Vishwakarma·9 min
1441
Higher-Order Functions in JavaScript
JavaScript

Higher-Order Functions in JavaScript

Higher-order functions are one of the most important ideas in JavaScript, and you're probably already using them without realizing it. This blog explains what makes a function "higher-order," why they matter, and how methods like map, filter, and setTimeout all rely on this same simple idea.

Vishal Vishwakarma·7 min
4800
React vs Next.js: What's Actually Different and When to Use Which

React vs Next.js: What's Actually Different and When to Use Which

React and Next.js are often confused - one is a UI library, the other is a full framework built on top of it. This post breaks down the real differences: rendering strategies, routing, API routes, and when to pick one over the other.

Sadie Sink·8 min
3720