Skip to main content

callback functions and callback hell in JavaScript

Callback function - a function you pass into another function so that function can call it later, usually after finishing some work.

Theory

TL;DR

  • Think of leaving your phone number at a restaurant. They call you when the table is ready instead of making you stand in line. That is a callback.
  • Callbacks split into two types: sync (.map(), .filter()) run immediately inside the loop; async (setTimeout, fs.readFile) run after the current call stack clears.
  • Callback hell happens when you chain 3+ async callbacks. The code drifts rightward, error handling repeats on every level, and adding one step means adding another layer of indentation.
  • Decision rule: one async step → callback is fine. Two or more chained steps → use Promises or async/await.

Quick example

javascript
function fetchUser(userId, callback) { setTimeout(() => { const user = { id: userId, name: 'Alice' }; callback(null, user); // error-first: null = no error }, 1000); } fetchUser(123, (err, user) => { if (err) return console.error(err); console.log(user.name); // "Alice" - prints after 1 second }); console.log('This runs first'); // sync code does not wait

The outer console.log runs immediately. The callback fires one second later because the event loop queues it and runs it only when the call stack is empty. That is the whole mechanism.

Sync vs async callbacks

Not all callbacks are async. .map() and .filter() call your function synchronously, line by line inside the method. setTimeout and fs.readFile are different: Node or the browser hands the operation to a background API, and your callback goes into the task queue. The event loop picks it up after the current stack clears.

This distinction trips people up in interviews. A classic question:

javascript
setTimeout(() => console.log('A'), 0); console.log('B'); // Output: "B" then "A" // 0ms delay does not mean "run now". It means "queue it".

Error-first callback pattern

Node.js standardized a convention: the first argument of every callback is the error. If there is no error, it is null. This makes failure-checking consistent across all async APIs.

javascript
fs.readFile('config.json', 'utf8', (err, data) => { if (err) { console.error('File missing:', err.message); return; // always return early on error } const config = JSON.parse(data); console.log(config); });

The most common bug I see in Node.js code is skipping the err check entirely. You get undefined in data with no hint of what failed.

Callback hell

When each async step needs the result from the previous one, you start nesting. After three levels, the code grows rightward. This is callback hell, sometimes called the pyramid of doom.

javascript
// Real pre-Promise API code looked like this getUser(userId, (err, user) => { if (err) return handleError(err); getOrders(user.id, (err, orders) => { if (err) return handleError(err); getOrderDetails(orders[0].id, (err, details) => { if (err) return handleError(err); getShippingInfo(details.shipId, (err, shipping) => { if (err) return handleError(err); console.log(shipping); }); }); }); });

The problem is not just formatting. Error handling duplicates at every level. Adding a step means another nest. Refactoring any part of this chain is slow and error-prone.

How to fix callback hell

Three approaches work in practice.

Promises flatten the chain into a readable sequence:

javascript
getUser(userId) .then(user => getOrders(user.id)) .then(orders => getOrderDetails(orders[0].id)) .then(details => getShippingInfo(details.shipId)) .then(shipping => console.log(shipping)) .catch(handleError); // one handler for the whole chain

async/await makes it look like synchronous code:

javascript
async function getShipping(userId) { const user = await getUser(userId); const orders = await getOrders(user.id); const details = await getOrderDetails(orders[0].id); return getShippingInfo(details.shipId); }

Named functions work as a quick refactor without switching to Promises. Pull callbacks out of each other:

javascript
function onUser(err, user) { if (err) return handleError(err); getOrders(user.id, onOrders); } function onOrders(err, orders) { if (err) return handleError(err); getOrderDetails(orders[0].id, onDetails); } getUser(userId, onUser); // flat, readable, same behavior

When to use callbacks

  • Single async operation like one addEventListener or a single setTimeout: callback works fine.
  • Array iteration with .map(), .filter(), .reduce(): sync callbacks, no nesting issue.
  • Two or more chained async steps: switch to Promises or async/await.
  • Error-prone async in Node.js: Promises give you one .catch() instead of repeating if (err) at every level.

Common mistakes

Ignoring the error parameter:

javascript
// Wrong - data is undefined when file is missing, no error shown fs.readFile('data.txt', (data) => console.log(data)); // Fix fs.readFile('data.txt', 'utf8', (err, data) => { if (err) return console.error(err); console.log(data); });

Using a variable before the callback runs:

javascript
let user; fetchUser(1, (err, result) => { user = result; // sets after ~500ms }); console.log(user); // undefined - this line runs before the callback fires

If you need the data, use it inside the callback or return a Promise.

Assuming setTimeout(..., 0) runs synchronously:

javascript
setTimeout(() => console.log('first'), 0); console.log('second'); // Output: "second", then "first"

Zero milliseconds means "queue it after the stack clears", not "run it now". This appears constantly in interviews.

Recursive polling without a stop condition:

javascript
// Runs forever function poll(cb) { setTimeout(() => poll(cb), 1000); } // Fix: add a counter function poll(cb, count = 0) { if (count >= 10) return cb(); setTimeout(() => poll(cb, count + 1), 1000); }

Real-world usage

  • Node.js/Express route handlers: app.get('/users', (req, res) => { fs.readFile(..., callback) })
  • Browser event listeners: button.addEventListener('click', handler)
  • Array methods in every JS codebase: .map(), .filter(), .forEach()
  • Legacy jQuery AJAX: $.get('/api/data', {}, callback) - still alive in older projects
  • setTimeout and setInterval for timers and polling

Follow-up questions

Q: What is a callback function?
A: A function passed as an argument to another function, called later after some work finishes. [1, 2, 3].forEach(n => console.log(n)) is the simplest example.

Q: What is callback hell and why does it matter?
A: Nesting 3+ async callbacks creates a rightward pyramid. Error handling repeats at every level, adding a step means adding a layer of indentation, and the code becomes hard to refactor.

Q: What is the error-first callback pattern?
A: A Node.js convention where the first argument is always the error (null if none). It standardizes error handling so callers always check the error before touching the data.

Q: What is the difference between sync and async callbacks?
A: Sync callbacks like .map() run immediately inside the calling function. Async callbacks like setTimeout get queued by the event loop and run after the current stack empties.

Q: How does the event loop handle async callbacks?
A: Node or the browser hands the async work to a background API. When it finishes, the callback goes into the task queue. The event loop moves it to the call stack only when the stack is empty, which is why setTimeout(..., 0) still runs after sync code.

Q: Why does sync recursion risk a stack overflow but async polling does not?
A: Sync recursion adds a frame to the call stack on every call. Node caps this around 10k frames. Async polling via setTimeout offloads each iteration to the task queue, so the stack never grows. Each poll starts fresh.

Examples

Basic async callback

javascript
function fetchUser(userId, callback) { setTimeout(() => { const user = { id: userId, name: 'Alice' }; callback(null, user); // null = no error }, 1000); } fetchUser(123, (err, user) => { if (err) return console.error(err); console.log(user.name); // "Alice" - after 1 second });

Express.js route with async file read

javascript
const fs = require('fs'); const express = require('express'); const app = express(); app.get('/user/:id', (req, res) => { fs.readFile(`users/${req.params.id}.json`, 'utf8', (err, data) => { if (err) return res.status(500).json({ error: 'User not found' }); res.json(JSON.parse(data)); // { "name": "Bob" } }); }); // GET /user/1 reads the file async, responds after ~10ms

The route handler itself is a callback. The fs.readFile callback is a second callback inside it. Two levels is still readable - this is where you draw the line.

Callback hell vs async/await

javascript
// Callback hell: 3 chained async operations getUser(123, (err, user) => { if (err) return handleError(err); getPosts(user.id, (err, posts) => { if (err) return handleError(err); getComments(posts[0].id, (err, comments) => { if (err) return handleError(err); console.log(comments); }); }); }); // async/await: same logic, no nesting async function loadComments(userId) { const user = await getUser(userId); const posts = await getPosts(user.id); const comments = await getComments(posts[0].id); return comments; // one try/catch wraps the whole thing }

The behavior is identical. The async/await version has one place for error handling and reads top to bottom like normal code.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?