What is a Callback Function in JS? Full Tutorial
Learn what callback functions are in JavaScript and how they work. Covers synchronous callbacks with array methods, asynchronous callbacks with setTimeout and events, error-first callbacks, callback patterns, and when to use callbacks vs promises.
A callback function is a function passed as an argument to another function, which then calls it at the right time. When you pass handleClick to addEventListener, or pass an arrow function to .map(), you are using callbacks. They are one of the most fundamental patterns in JavaScript because the language relies on them for asynchronous operations, event handling, and array transformations.
This tutorial explains what callbacks are, shows synchronous and asynchronous examples, covers the error-first pattern from Node.js, and addresses the problem known as callback hell.
What Makes a Function a Callback
A callback is not a special type of function. It is any function passed as an argument to another function:
function greet(name) {
console.log(`Hello, ${name}!`);
}
function processUserInput(callback) {
const name = "Alice";
callback(name); // calling the callback
}
processUserInput(greet);
// Hello, Alice!The function greet becomes a callback when it is passed to processUserInput. The receiving function decides when and how to call it.
Named vs Anonymous Callbacks
// Named callback
function double(n) {
return n * 2;
}
const results1 = [1, 2, 3].map(double);
// Anonymous callback
const results2 = [1, 2, 3].map(function (n) {
return n * 2;
});
// Arrow function callback
const results3 = [1, 2, 3].map((n) => n * 2);
// All produce [2, 4, 6]| Style | Reusable | Readable | Debuggable |
|---|---|---|---|
| Named function | Yes | High | Stack trace shows name |
| Anonymous function | No | Medium | Stack trace shows anonymous |
| Arrow function | No | High for short logic | Stack trace shows anonymous |
Synchronous Callbacks
Synchronous callbacks run immediately, before the calling function returns. Array methods are the most common example:
Array Method Callbacks
const numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
// .filter() calls the callback for each element
const evens = numbers.filter((n) => n % 2 === 0);
console.log(evens); // [2, 4, 6, 8, 10]
// .map() transforms each element using the callback
const doubled = numbers.map((n) => n * 2);
console.log(doubled); // [2, 4, 6, 8, 10, 12, 14, 16, 18, 20]
// .reduce() accumulates a result using the callback
const sum = numbers.reduce((total, n) => total + n, 0);
console.log(sum); // 55
// .forEach() runs the callback for each element (no return value)
numbers.forEach((n) => console.log(n));Custom Synchronous Callback
function repeat(times, callback) {
for (let i = 0; i < times; i++) {
callback(i);
}
}
repeat(3, (index) => {
console.log(`Iteration ${index}`);
});
// Iteration 0
// Iteration 1
// Iteration 2Sort with a Callback
const users = [
{ name: "Charlie", age: 35 },
{ name: "Alice", age: 28 },
{ name: "Bob", age: 32 },
];
// The comparison callback determines sort order
users.sort((a, b) => a.age - b.age);
console.log(users);
// [{ name: "Alice", age: 28 }, { name: "Bob", age: 32 }, { name: "Charlie", age: 35 }]Synchronous Callbacks Run in Order
Synchronous callbacks execute in sequence. The code after the function call does not run until all callbacks have finished. This is different from asynchronous callbacks, which are scheduled for later.
Asynchronous Callbacks
Asynchronous callbacks run later, after some operation completes. JavaScript uses them for timers, events, network requests, and file operations.
setTimeout and setInterval
console.log("Before");
setTimeout(() => {
console.log("Inside timeout (after 1 second)");
}, 1000);
console.log("After");
// Output order:
// Before
// After
// Inside timeout (after 1 second)The callback passed to setTimeout does not run immediately. JavaScript schedules it and continues executing the code below.
// setInterval calls the callback repeatedly
let count = 0;
const intervalId = setInterval(() => {
count++;
console.log(`Tick ${count}`);
if (count >= 3) {
clearInterval(intervalId);
console.log("Stopped");
}
}, 1000);
// Tick 1 (after 1s)
// Tick 2 (after 2s)
// Tick 3 (after 3s)
// StoppedEvent Listeners
// DOM event callbacks
document.getElementById("btn").addEventListener("click", (event) => {
console.log("Button clicked!");
console.log(`Target: ${event.target.tagName}`);
});
// The callback runs only when the user clicks the button
// It could be called 0 times, 1 time, or 100 timesMultiple Callbacks: Success and Error
function fetchUser(id, onSuccess, onError) {
// Simulating an API call
setTimeout(() => {
if (id > 0) {
onSuccess({ id, name: "Alice", email: "alice@test.com" });
} else {
onError(new Error("Invalid user ID"));
}
}, 500);
}
fetchUser(
1,
(user) => console.log("Got user:", user),
(error) => console.log("Error:", error.message)
);
// Got user: { id: 1, name: "Alice", email: "alice@test.com" }
fetchUser(
-1,
(user) => console.log("Got user:", user),
(error) => console.log("Error:", error.message)
);
// Error: Invalid user IDThe Error-First Callback Pattern
Node.js established a convention where callbacks always receive an error as the first argument:
function readConfig(path, callback) {
// Simulated file read
setTimeout(() => {
if (path === "/valid/config.json") {
callback(null, { theme: "dark", lang: "en" });
} else {
callback(new Error(`File not found: ${path}`), null);
}
}, 100);
}
// Error-first pattern: always check error first
readConfig("/valid/config.json", (error, data) => {
if (error) {
console.log("Failed:", error.message);
return;
}
console.log("Config:", data);
});
// Config: { theme: "dark", lang: "en" }
readConfig("/missing/file.json", (error, data) => {
if (error) {
console.log("Failed:", error.message);
return;
}
console.log("Config:", data);
});
// Failed: File not found: /missing/file.json| Position | Contains | When present |
|---|---|---|
| First argument | Error or null | Always |
| Second argument | Result data | Only when error is null |
Always Check the Error
In error-first callbacks, always check if the error argument is truthy before using the result. Skipping the error check can lead to using null or undefined data and hiding bugs.
How JavaScript Manages Async Callbacks
When JavaScript encounters an async callback (like setTimeout), it does not stop and wait. It hands the timer to the browser or Node.js runtime and continues running the next line. When the timer finishes, the callback is placed in a queue. The event loop picks it up and runs it when the call stack is empty:
console.log("1: Start");
setTimeout(() => {
console.log("2: Timeout callback");
}, 0); // even with 0ms delay
console.log("3: End");
// Output:
// 1: Start
// 3: End
// 2: Timeout callbackEven a 0ms timeout runs after the current code finishes because the callback goes through the event loop queue.
// Visualizing the sequence
console.log("A");
setTimeout(() => console.log("B"), 0);
setTimeout(() => console.log("C"), 0);
console.log("D");
// A, D, B, C
// Synchronous code runs first, then queued callbacks in orderCallback Hell: The Nesting Problem
When multiple asynchronous operations depend on each other, callbacks nest deeper and deeper:
// Each step depends on the previous result
getUser(userId, (error, user) => {
if (error) {
handleError(error);
return;
}
getOrders(user.id, (error, orders) => {
if (error) {
handleError(error);
return;
}
getOrderDetails(orders[0].id, (error, details) => {
if (error) {
handleError(error);
return;
}
getShipping(details.shipmentId, (error, tracking) => {
if (error) {
handleError(error);
return;
}
displayTrackingInfo(tracking);
});
});
});
});This pyramid of nested callbacks is hard to read, debug, and maintain. Solutions include:
| Solution | Approach | Complexity |
|---|---|---|
| Named functions | Extract each callback into a named function | Low |
| Promises | Chain .then() calls instead of nesting | Medium |
| async/await | Write async code that looks synchronous | Low |
Fixing with Named Functions
function handleTracking(error, tracking) {
if (error) return handleError(error);
displayTrackingInfo(tracking);
}
function handleDetails(error, details) {
if (error) return handleError(error);
getShipping(details.shipmentId, handleTracking);
}
function handleOrders(error, orders) {
if (error) return handleError(error);
getOrderDetails(orders[0].id, handleDetails);
}
function handleUser(error, user) {
if (error) return handleError(error);
getOrders(user.id, handleOrders);
}
getUser(userId, handleUser);The logic is the same, but the code is flat and each step has a descriptive name.
Practical Patterns
Retry with Callback
function retryOperation(operation, maxRetries, callback) {
let attempts = 0;
function attempt() {
attempts++;
operation((error, result) => {
if (!error) {
callback(null, result);
return;
}
if (attempts >= maxRetries) {
callback(new Error(`Failed after ${maxRetries} attempts: ${error.message}`));
return;
}
console.log(`Attempt ${attempts} failed, retrying...`);
setTimeout(attempt, 1000 * attempts); // exponential-ish backoff
});
}
attempt();
}Callback-Based Array Processing
function processItems(items, processFn, callback) {
const results = [];
let completed = 0;
if (items.length === 0) {
callback(null, []);
return;
}
items.forEach((item, index) => {
processFn(item, (error, result) => {
if (error) {
callback(error);
return;
}
results[index] = result;
completed++;
if (completed === items.length) {
callback(null, results);
}
});
});
}
// Usage
processItems(
[1, 2, 3],
(num, cb) => setTimeout(() => cb(null, num * 2), 100),
(error, results) => console.log(results) // [2, 4, 6]
);Middleware Pattern
function runMiddleware(middlewares, context, done) {
let index = 0;
function next(error) {
if (error) {
done(error);
return;
}
if (index >= middlewares.length) {
done(null, context);
return;
}
const middleware = middlewares[index++];
middleware(context, next);
}
next();
}
// Usage
const middlewares = [
(ctx, next) => { ctx.timestamp = Date.now(); next(); },
(ctx, next) => { ctx.user = "Alice"; next(); },
(ctx, next) => { ctx.authorized = true; next(); },
];
runMiddleware(middlewares, {}, (error, result) => {
console.log(result);
// { timestamp: 1738746000000, user: "Alice", authorized: true }
});Common Mistakes
Forgetting to Return After Error
// BUG: code continues after error
function process(data, callback) {
if (!data) {
callback(new Error("No data"));
// Missing return! Code below still runs
}
// This line runs even when data is null
const result = data.toUpperCase(); // TypeError!
callback(null, result);
}
// FIX: always return after calling back with an error
function process(data, callback) {
if (!data) {
return callback(new Error("No data")); // return stops execution
}
const result = data.toUpperCase();
callback(null, result);
}Calling the Callback Multiple Times
// BUG: callback called twice
function search(items, query, callback) {
items.forEach((item) => {
if (item === query) {
callback(null, item); // called for EVERY match!
}
});
callback(new Error("Not found")); // also called even if found
}
// FIX: use a flag or return early
function search(items, query, callback) {
const found = items.find((item) => item === query);
if (found) {
callback(null, found);
} else {
callback(new Error("Not found"));
}
}Rune AI
Key Insights
- A callback is any function passed as an argument: it describes usage, not structure
- Synchronous callbacks run immediately: array methods like
mapandfilteruse sync callbacks - Async callbacks run later via the event loop: timers, events, and I/O use async callbacks
- Error-first pattern: always check the first argument for errors before using results
- Avoid deep nesting: use named functions, promises, or async/await to flatten callback chains
Frequently Asked Questions
What is the difference between a callback and a regular function?
When should I use callbacks instead of promises?
Can async functions be used as callbacks?
Why does my callback sometimes run before I expect it to?
How do I convert callback-based code to promises?
Conclusion
Callback functions are functions passed as arguments to other functions. They are the foundation of event handling, array processing, and asynchronous programming in JavaScript. Synchronous callbacks (array methods, sort comparisons) run immediately in sequence. Asynchronous callbacks (timers, events, network requests) are scheduled by the runtime and run later through the event loop. The error-first pattern (callback(error, result)) standardizes error handling. When callbacks nest too deeply, extract named functions, or migrate to promises and async/await for cleaner control flow.
More in this topic
OffscreenCanvas API in JS for UI Performance
Master the OffscreenCanvas API to offload rendering from the main thread. Covers worker-based 2D and WebGL rendering, animation loops inside workers, bitmap transfer, double buffering, chart rendering pipelines, image processing, and performance measurement strategies.
Advanced Web Workers for High Performance JS
Master Web Workers for truly parallel JavaScript execution. Covers dedicated and shared workers, structured cloning, transferable objects, SharedArrayBuffer with Atomics, worker pools, task scheduling, Comlink RPC patterns, module workers, and performance profiling strategies.
JavaScript Macros and Abstract Code Generation
Master JavaScript code generation techniques for compile-time and runtime metaprogramming. Covers AST manipulation, Babel plugin authorship, tagged template literals as macros, code generation pipelines, source-to-source transformation, compile-time evaluation, and safe eval alternatives.