Ad – 728Γ—90
πŸš€ Advanced JS

JavaScript Callbacks – Asynchronous Code with Functions

A callback is simply a function passed as an argument to another function, to be called at a later point. Callbacks are JavaScript's oldest async pattern β€” they power setTimeout, DOM event listeners, Array.forEach, and Node.js file I/O. Understanding callbacks deeply β€” including their pitfalls like callback hell β€” is essential before learning Promises and async/await, which solve the same problems with better ergonomics.

⏱️ 20 min read 🎯 Advanced πŸ“… Updated 2026

What Is a Callback?

A callback is a function you pass to another function as an argument. The receiving function decides when and how to call it. Callbacks can be synchronous (called immediately) or asynchronous (called after some delay or event).

JavaScript
// Synchronous callback β€” called immediately
function greet(name, callback) {
  const message = `Hello, ${name}!`;
  callback(message);            // called right now
}

greet('Alice', (msg) => console.log(msg));
// Hello, Alice!

// Asynchronous callback β€” called later
function fetchUserLater(userId, callback) {
  console.log('Fetching user...');
  setTimeout(() => {
    const user = { id: userId, name: 'Bob' };
    callback(null, user);     // error-first convention
  }, 1000);
}

fetchUserLater(42, (err, user) => {
  if (err) return console.error(err);
  console.log('Got user:', user.name);
});

console.log('This runs BEFORE the callback');
β–Ά Output
Hello, Alice!
Fetching user...
This runs BEFORE the callback
Got user: Bob       ← (after ~1 second)

Synchronous Callbacks

Many built-in array methods accept synchronous callbacks. The callback is called immediately, inline, as part of the method's execution.

JavaScript
const numbers = [5, 2, 8, 1, 9, 3];

// forEach β€” callback for each element
numbers.forEach((n, i) => console.log(`[${i}] = ${n}`));

// sort β€” comparator callback
const sorted = [...numbers].sort((a, b) => a - b);
console.log('Sorted:', sorted);

// filter + map chain
const result = numbers
  .filter(n => n > 3)          // [5, 8, 9]
  .map(n => n * 2);            // [10, 16, 18]
console.log('Filtered & mapped:', result);

// reduce β€” accumulator callback
const sum = numbers.reduce((acc, n) => acc + n, 0);
console.log('Sum:', sum);
β–Ά Output
[0] = 5
[1] = 2
...
Sorted: [1, 2, 3, 5, 8, 9]
Filtered & mapped: [10, 16, 18]
Sum: 28

Asynchronous Callbacks

Asynchronous callbacks are deferred β€” they run after an I/O operation, timer, or event fires. The current call stack must finish before any async callback runs.

JavaScript
// setTimeout
const timerId = setTimeout(() => {
  console.log('Fired after 500ms');
}, 500);

// setInterval β€” repeated callback
let count = 0;
const intervalId = setInterval(() => {
  count++;
  console.log(`Tick #${count}`);
  if (count === 3) clearInterval(intervalId);
}, 200);

// DOM event listener (browser)
// document.querySelector('#btn').addEventListener('click', (e) => {
//   console.log('Button clicked!', e.target);
// });

console.log('Synchronous code runs first');
// Output order: synchronous β†’ ticks β†’ timeout
β–Ά Output
Synchronous code runs first
Tick #1   (after 200ms)
Tick #2   (after 400ms)
Tick #3   (after 600ms) β†’ interval cleared
Fired after 500ms

Node.js Error-First Callback Convention

Node.js standardised the callback signature as (err, data) β€” the first parameter is always an error (or null if successful), the second is the result. This makes error handling consistent across APIs.

JavaScript
// Simulating Node.js-style fs.readFile
function readConfig(path, callback) {
  setTimeout(() => {
    if (!path.endsWith('.json')) {
      callback(new Error(`Invalid config path: ${path}`), null);
      return;
    }
    callback(null, { theme: 'dark', language: 'en' });
  }, 100);
}

// Correct usage: always check err first
readConfig('app.json', (err, config) => {
  if (err) {
    console.error('Failed to read config:', err.message);
    return;                 // ← return early prevents using bad data
  }
  console.log('Config loaded:', config);
});

readConfig('app.yaml', (err, config) => {
  if (err) {
    console.error('Failed to read config:', err.message);
    return;
  }
  console.log('Config:', config);
});
β–Ά Output
Config loaded: { theme: 'dark', language: 'en' }
Failed to read config: Invalid config path: app.yaml
⚠️
Always check err before using data

A very common bug is accessing data without checking err. If err is not null, data will be null or undefined β€” and proceeding will cause a secondary error that obscures the root cause. Always if (err) return handleError(err) as the first line.

Callback Hell – The Pyramid of Doom

When you chain multiple async operations that depend on each other, nested callbacks create a deeply indented, hard-to-read structure called "callback hell" or the "pyramid of doom".

JavaScript
// The pyramid of doom β€” each step nests inside the previous
function login(username, password, cb) {
  setTimeout(() => cb(null, { userId: 1, token: 'abc123' }), 100);
}
function getProfile(userId, cb) {
  setTimeout(() => cb(null, { userId, name: 'Alice', role: 'admin' }), 100);
}
function getPermissions(role, cb) {
  setTimeout(() => cb(null, ['read', 'write', 'delete']), 100);
}
function loadDashboard(permissions, cb) {
  setTimeout(() => cb(null, `Dashboard with [${permissions.join(', ')}]`), 100);
}

// Deeply nested β€” hard to read, hard to maintain
login('alice', 'pass', (err, session) => {
  if (err) return console.error(err);
  getProfile(session.userId, (err, profile) => {
    if (err) return console.error(err);
    getPermissions(profile.role, (err, permissions) => {
      if (err) return console.error(err);
      loadDashboard(permissions, (err, html) => {
        if (err) return console.error(err);
        console.log(html);
        // ← code keeps moving to the right...
      });
    });
  });
});
β–Ά Output
Dashboard with [read, write, delete]

Flattening Callback Hell with Named Functions

You can flatten the pyramid by extracting each callback into a named function. The logic becomes linear and each step is easy to test in isolation.

JavaScript
// Same operations as above β€” but flat and readable
function onDashboardLoaded(err, html) {
  if (err) return console.error('Dashboard error:', err);
  console.log(html);
}

function onPermissionsLoaded(err, permissions) {
  if (err) return console.error('Permissions error:', err);
  loadDashboard(permissions, onDashboardLoaded);
}

function onProfileLoaded(err, profile) {
  if (err) return console.error('Profile error:', err);
  getPermissions(profile.role, onPermissionsLoaded);
}

function onLogin(err, session) {
  if (err) return console.error('Login error:', err);
  getProfile(session.userId, onProfileLoaded);
}

// Clean entry point β€” reads top to bottom
login('alice', 'pass', onLogin);
β–Ά Output
Dashboard with [read, write, delete]
πŸ’‘
Converting callbacks to Promises

Node.js's util.promisify(fn) wraps any error-first callback function into one that returns a Promise. You can then use .then() or async/await for clean sequential logic.

Converting Callbacks to Promises

JavaScript
// Manual promisify wrapper
function promisify(fn) {
  return function (...args) {
    return new Promise((resolve, reject) => {
      fn(...args, (err, data) => {
        if (err) reject(err);
        else resolve(data);
      });
    });
  };
}

const loginP       = promisify(login);
const getProfileP  = promisify(getProfile);
const getPermsP    = promisify(getPermissions);
const loadDashP    = promisify(loadDashboard);

// Now use async/await β€” clean, linear, easy error handling
async function loadApp() {
  try {
    const session     = await loginP('alice', 'pass');
    const profile     = await getProfileP(session.userId);
    const permissions = await getPermsP(profile.role);
    const html        = await loadDashP(permissions);
    console.log(html);
  } catch (err) {
    console.error('App load failed:', err.message);
  }
}

loadApp();
β–Ά Output
Dashboard with [read, write, delete]
PatternReadabilityError handlingModern usage
Nested callbacksPoor (pyramid)Manual at each levelLegacy code only
Named callbacksBetter (flat)Manual at each levelAcceptable for simple flows
Promises (.then)Good (chain).catch() onceCommon
async/awaitBest (linear)try/catch oncePreferred
Ad – 336Γ—280

πŸ‹οΈ Practical Exercise

Simulate a multi-step file processing pipeline using error-first callbacks:

  • Step 1 readFile(path, cb) β€” returns fake file content after 100ms (fail if path is empty).
  • Step 2 parseData(content, cb) β€” parses JSON after 50ms (fail if content is invalid JSON).
  • Step 3 saveResult(data, cb) β€” "saves" after 80ms.
  • Write the pipeline first with nested callbacks, then refactor to named functions, then promisify and use async/await.

πŸ”₯ Challenge Exercise

Implement a waterfall(tasks, finalCallback) utility function that runs an array of async error-first callback functions in sequence, passing the result of each to the next. If any task errors, skip remaining tasks and call finalCallback(err). This is similar to the classic async.waterfall pattern.

Interview Questions

  • What is the difference between a synchronous and asynchronous callback?
  • What is the Node.js error-first callback convention and why does it exist?
  • What is callback hell and what are the strategies to avoid it?
  • Why do Promises and async/await replace callbacks for complex async flows?
  • How would you manually convert an error-first callback API into a Promise?

πŸ“‹ Summary

  • A callback is a function passed to another function to be invoked later.
  • Synchronous callbacks run immediately (forEach, sort); async callbacks run after a delay or event.
  • Node.js error-first convention: (err, data) β€” always check err before using data.
  • Callback hell arises from deeply nested async chains β€” fix with named functions or Promises.
  • promisify wraps error-first callbacks into Promise-returning functions.
  • For new code, prefer Promises or async/await over raw callbacks for sequential async logic.

Frequently Asked Questions

Are callbacks always asynchronous? +

No. A callback is just a function passed as an argument. Whether it runs synchronously or asynchronously depends entirely on how the receiving function calls it. Array.forEach calls its callback synchronously; setTimeout calls it asynchronously.

Can I use async/await inside a forEach callback? +

You can mark the callback async, but forEach will not await it β€” it will fire and forget each promise. Use for...of with await for sequential async iteration, or Promise.all(array.map(async item => ...)) for parallel execution.

What is util.promisify in Node.js? +

require('util').promisify(fn) takes any function that follows the error-first callback convention and returns a new function that returns a Promise instead. Node.js's built-in fs.promises namespace provides pre-promisified versions of all file system methods.