
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.
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.
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:
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
// 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:
function handleClick() {
console.log("Button was clicked");
}
document.getElementById("btn").addEventListener("click", handleClick);
Anonymous function as callback:
document.getElementById("btn").addEventListener("click", function() {
console.log("Button was clicked");
});
Arrow function as callback (modern style):
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:
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:
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:
// 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.
console.log("1 - Start");
setTimeout(function() {
console.log("3 - Inside timeout");
}, 2000);
console.log("2 - End");
Output:
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 - thesetTimeout 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:
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:
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
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.
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,
nullif everything is fineSecond argument onwards: the actual result data
// 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:
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:
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:
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:
// 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:
button.addEventListener("click", (event) => {
console.log("Clicked:", event.target);
});
window.addEventListener("resize", () => {
console.log("Window resized");
});
Array methods - always use synchronous callbacks:
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:
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:
// 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:
// 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:
// 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:
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:
// 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.
Related Articles
More insights you might enjoy

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.

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.

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.

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

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.

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.

Comments
Sign in to join the conversation