Beginner1 questionFull Guide

JavaScript Callback Interview Questions

Callbacks are the foundation of JavaScript async. Learn callback hell, inversion of control, and how Promises solve these problems.

The Mental Model

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.

The Explanation

Callbacks are just functions passed as arguments

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 vs asynchronous callbacks — a critical distinction

// 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.

The Node.js error-first callback convention

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
})

Callback Hell — why it happens, what it looks like

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.

Escaping callback hell — three strategies

Strategy 1: Named functions (flatten the pyramid)

// 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

Strategy 2: Promisify (modernize the API)

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)
      })
    })
  }
}

Strategy 3: Async libraries (for complex flows)

// 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 and closures — how they share state

// 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)
}

Inversion of control — the fundamental problem with callbacks

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 spec

Common Misconceptions

⚠️

Many 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.

Where You'll See This in Real Code

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.

Interview Cheat Sheet

  • Callback: a function passed as an argument to another function, called by the receiver
  • Synchronous callbacks: called immediately (map, filter, forEach, sort)
  • Asynchronous callbacks: called later (setTimeout, addEventListener, fs.readFile)
  • Error-first convention (Node.js): callback(err, result) — null err means success
  • Callback hell: nested async callbacks, pyramid of doom, error handling repeated per level
  • Inversion of control: giving a function to someone else to call — core limitation
  • Flatten: extract named functions to eliminate nesting without changing the pattern
  • Promisify: wrap callback APIs in new Promise() to get Promise-based interface
  • util.promisify: automates promisification for error-first Node.js callbacks only
💡

How to Answer in an Interview

  • 1.Show callback hell visually (pyramid of doom), then show the Promise equivalent
  • 2.Explain IOC risk: what if the callback is called twice, never, or with wrong args?
  • 3.Distinguish synchronous vs asynchronous callbacks immediately — most devs conflate them
  • 4.Inversion of control is the sophisticated answer to "why are Promises better than callbacks" — go beyond "less nesting"
  • 5.The loop closure bug (var + setTimeout) is the most common callback output question — know both the bug and both fixes (let, IIFE)
  • 6.Explaining util.promisify's internal implementation shows you understand both callbacks and Promises deeply
📖 Deep Dive Articles
map() vs forEach() in JavaScript: Which One to Use and Why It Matters7 min readPromise.all vs allSettled vs race vs any: The Complete Comparison9 min readTop 50 JavaScript Interview Questions (With Deep Answers)18 min readJavaScript Event Loop Explained: Call Stack, Microtasks, and Macrotasks11 min readJavaScript Promises & Async/Await: The Complete Mental Model12 min read

Practice Questions

1 question
#01

What is callback hell and how do you avoid it?

🟢 EasyAsync JS PRO💡 Pyramid of doom — nested callbacks; solve with Promises/async-await

Related Topics

JavaScript Promise Interview Questions
Intermediate·7–12 Qs
JavaScript async/await Interview Questions
Intermediate·5–8 Qs
🎯

Can you answer these under pressure?

Reading answers is not the same as knowing them. Practice saying them out loud with AI feedback — that's what builds real interview confidence.

Practice Free →Try Output Quiz