Callbacks are the foundation of JavaScript async. Learn callback hell, inversion of control, and how Promises solve these problems.
Picture leaving your phone number at a busy restaurant. You don't wait at the door. You go about your evening, and the restaurant calls you back when your table is ready. You gave them a function to call — your phone number — and they called it when the relevant thing happened. That's a callback. In code: you hand a function to another function and say "call this when you're done." You don't wait. You move on. The callback is your phone number — a deferred instruction that executes at a future point in time when the right conditions are met. The receiver decides when to invoke it. The key insight: callbacks are not inherently about async code. Callbacks are just functions passed as arguments, and that pattern is everywhere — synchronous callbacks like Array.map's argument, event callbacks like addEventListener, and asynchronous callbacks like setTimeout's first argument are all the same concept. What changes is when the callback is invoked: immediately, on an event, or after an async operation completes.
Before anything else: a callback is not special syntax. It's just a function you pass to another function. The receiving function decides when to call it.
// Synchronous callback — called immediately, inline
function doTwice(fn) {
fn()
fn()
}
doTwice(() => console.log('hello')) // 'hello', 'hello'
// Event-driven callback — called when the event fires
document.addEventListener('click', (event) => {
console.log('clicked at', event.clientX, event.clientY)
})
// Async callback — called when the async work completes
setTimeout(() => {
console.log('1 second has passed')
}, 1000)
// All three are the same concept — a function given to another function to call
// Synchronous callback — runs BEFORE the next line
const doubled = [1, 2, 3].map(n => n * 2)
console.log(doubled) // [2, 4, 6] — map's callback ran first, synchronously
// Asynchronous callback — runs AFTER the current call stack clears
setTimeout(() => console.log('async'), 0)
console.log('sync')
// Output: 'sync', 'async'
// Even with 0ms delay, setTimeout's callback is a macrotask — runs after sync code
// This inconsistency is a common bug source:
function getUserData(id, callback) {
if (cache[id]) {
callback(cache[id]) // synchronous if cached
} else {
fetch(`/api/${id}`)
.then(r => r.json())
.then(callback) // asynchronous if not cached
}
}
// Callers can't know if their callback fires sync or async — unpredictable!
The rule (popularised by Node.js): always call callbacks asynchronously. If you have a synchronous result, use process.nextTick() or Promise.resolve().then() to defer it. Consistent behavior is safer than micro-optimizing away the async hop.
Node.js standardized a callback signature: the first argument is always an error (null if none), and subsequent arguments are the results. This is the "error-first" or "errback" pattern. Every Node.js core API uses it.
const fs = require('fs')
// Error-first callback: (err, result)
fs.readFile('./config.json', 'utf8', (err, data) => {
if (err) {
console.error('Failed to read file:', err.message)
return // ← always return after handling the error
}
const config = JSON.parse(data)
console.log(config)
})
// Building your own following the same convention:
function divide(a, b, callback) {
if (b === 0) {
callback(new Error('Division by zero'))
return
}
callback(null, a / b)
}
divide(10, 2, (err, result) => {
if (err) return console.error(err.message)
console.log(result) // 5
})
When async operations depend on each other's results, callbacks nest. Three levels deep, code becomes hard to read. Five levels deep, it becomes unmaintainable. This is "callback hell" or the "pyramid of doom."
// Callback hell — real-world example: login → get profile → get friends → get their posts
loginUser(username, password, (err, user) => {
if (err) return handleError(err)
getUserProfile(user.id, (err, profile) => {
if (err) return handleError(err)
getFriends(profile.id, (err, friends) => {
if (err) return handleError(err)
friends.forEach(friend => {
getRecentPosts(friend.id, (err, posts) => {
if (err) return handleError(err)
posts.forEach(post => {
renderPost(post) // now we're 5 levels deep
})
})
})
})
})
})
The problems: deeply indented code that's hard to read, error handling repeated at every level, variables from outer scopes trapped in closures at each level, and the logic flows right then down in an inverted triangle.
// Extract each level into a named function
function onLogin(err, user) {
if (err) return handleError(err)
getUserProfile(user.id, onProfile)
}
function onProfile(err, profile) {
if (err) return handleError(err)
getFriends(profile.id, onFriends)
}
function onFriends(err, friends) {
if (err) return handleError(err)
friends.forEach(f => getRecentPosts(f.id, onPosts))
}
function onPosts(err, posts) {
if (err) return handleError(err)
posts.forEach(renderPost)
}
loginUser(username, password, onLogin)
// Same logic, flat structure — no more pyramid
const { promisify } = require('util')
// Convert Node.js callback functions to Promise-returning functions
const readFile = promisify(fs.readFile)
const writeFile = promisify(fs.writeFile)
// Now use them with async/await
async function processConfig() {
const data = await readFile('./config.json', 'utf8')
const config = JSON.parse(data)
config.updated = Date.now()
await writeFile('./config.json', JSON.stringify(config))
}
// Manual promisification — how util.promisify works:
function promisify(fn) {
return function(...args) {
return new Promise((resolve, reject) => {
fn(...args, (err, result) => {
if (err) reject(err)
else resolve(result)
})
})
}
}
// async.js — control flow for callback-based code (pre-Promises era)
const async = require('async')
// Series — run in order, each waits for previous
async.series([
callback => loginUser(username, password, callback),
callback => getUserProfile(userId, callback),
callback => getFriends(profileId, callback),
], (err, results) => {
if (err) return handleError(err)
const [user, profile, friends] = results
})
// Callbacks close over surrounding variables — this is how they share context
function createCounter() {
let count = 0 // closed over by the callback
return {
increment: () => count++, // callback — closes over count
decrement: () => count--, // callback — same count
getCount: () => count // callback — same count
}
}
const counter = createCounter()
counter.increment()
counter.increment()
counter.getCount() // 2
// The classic loop bug — callbacks close over a shared variable
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100) // all print 3 — share the same i
}
// Fix with let (block-scoped, new i per iteration)
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100) // 0, 1, 2
}
// Fix with IIFE (pre-ES6)
for (var i = 0; i < 3; i++) {
;(function(j) {
setTimeout(() => console.log(j), 100) // 0, 1, 2 — j is captured per call
})(i)
}
When you pass a callback to a third-party function, you lose control over how it's called. The third party decides: when to call it, how many times, with what arguments, whether to call it at all, synchronously or asynchronously. Promises were designed specifically to reclaim that control.
// You have no guarantee the library calls your callback:
// - Once (it might call it multiple times)
// - At all (it might swallow errors and never call it)
// - Asynchronously (it might call it synchronously, breaking your assumptions)
// - With the right arguments (it might pass garbage)
thirdPartyLibrary.process(data, function(result) {
chargeCustomer(result) // called multiple times = charged multiple times!
})
// With Promises, you take back control:
// A Promise settles exactly once — fulfilled or rejected, never both, never twice
thirdPartyLibrary.processAsync(data)
.then(result => chargeCustomer(result))
.catch(err => handleError(err))
// .then() on a settled Promise only ever fires once — guaranteed by specMany devs think callbacks are always asynchronous — but actually callbacks are just functions passed as arguments, and they can be called synchronously (like Array.map's callback) or asynchronously (like setTimeout's callback). The calling function decides when to invoke the callback. The pattern itself has nothing to do with timing.
Many devs think error-first callbacks are a universal JavaScript convention — but actually error-first is a Node.js convention. Browser APIs and DOM events use different signatures — addEventListener callbacks receive an event object as the first argument with no error parameter. The error-first pattern is Node.js-specific, not language-wide.
Many devs think deeply nested callbacks can't be fixed without switching to Promises — but actually extracting named functions (flattening the pyramid by naming each callback) is a structural refactoring that eliminates the nesting without changing the underlying callback pattern. Named functions also improve stack traces and make each step independently testable.
Many devs think Promises completely replaced callbacks — but actually DOM event listeners (addEventListener), array methods (map, filter, forEach), observer patterns, and many browser APIs still use callbacks and always will. Callbacks are foundational. Promises build on top of them. async/await builds on top of Promises. All three layers coexist in every codebase.
Many devs think the only problem with callbacks is syntax depth — but actually the deeper problem is inversion of control: you're trusting a third-party function to call your callback correctly. Promises solve this by making the caller responsible for handling the result rather than handing control to the callee. The syntax improvement of async/await is secondary to this trust model change.
Many devs think util.promisify works on any callback-based function — but actually util.promisify only works on functions that follow the Node.js error-first convention (err, result). Functions with different callback signatures, multiple result arguments, or callbacks called multiple times require custom Promise wrappers.
Node.js's entire original ecosystem was callback-based — every npm package from 2010–2016 used error-first callbacks. The shift to Promises happened gradually, and today's Node.js has two APIs in parallel: the legacy fs.readFile(path, callback) and the modern fs.promises.readFile(path). Both exist because millions of projects still use the callback API, and the migration path is util.promisify.
Browser's IndexedDB API is entirely callback/event-based — IDBRequest objects fire onsuccess and onerror events rather than returning Promises. This API predates Promises and was never updated to use them. Libraries like idb (Jake Archibald) exist solely to wrap IndexedDB's callback API in a clean Promise interface, illustrating exactly what promisification looks like at scale.
React's useEffect hook uses a synchronous callback (the effect function itself) but the cleanup it returns is called asynchronously when the component unmounts or before the next effect runs. Understanding that useEffect's callback is synchronous while its cleanup is deferred explains many subtle React behavior patterns.
WebSocket message handling uses callbacks registered with event listeners — ws.onmessage = (event) => process(event.data) or ws.addEventListener('message', handler). These are persistent callbacks that fire many times over the connection's lifetime, which is fundamentally incompatible with Promise semantics (Promises settle once). This is why long-lived event streams use callbacks or observables, never Promises.
Webpack's plugin system is built on callbacks — plugins tap into lifecycle hooks (compiler.hooks.emit.tap('PluginName', callback)) that fire at specific points in the build process. The tap system is a publish-subscribe callback architecture at industrial scale, and understanding callbacks deeply makes understanding Webpack plugins straightforward.
Mocha test framework's done callback is the original way to test async code — it('should work', function(done) { asyncOp(result => { assert(result); done() }) }). It's still supported alongside async/await, and understanding why done exists (before async test runners, there was no way to tell the test framework the test was complete) shows the history of async JavaScript testing.
What is callback hell and how do you avoid it?
Reading answers is not the same as knowing them. Practice saying them out loud with AI feedback — that's what builds real interview confidence.