JavaScript Callbacks and Async Programming 2026 Tutorial

JavaScript Callbacks: Sync, Async, Patterns, and Callback Hell Explained

Callbacks are the original async pattern in JavaScript — a function passed as an argument to another function, to be called later when a task completes. Every JavaScript developer encounters callbacks daily: event handlers, array methods, timers, API requests, and Node.js I/O all rely on them. This guide covers how callbacks work, the patterns built around them, the infamous “callback hell” problem, and how to write clean callback code before moving on to Promises and async/await.

Before diving in, make sure you understand functions and arrow functions and closures — callbacks depend on both concepts.

What Are Callbacks

A callback is simply a function passed as an argument to another function. The receiving function “calls back” your function at the appropriate time:

function greet(name, callback) {
  const message = `Hello, ${name}!`;
  callback(message);
}

greet('Chirag', (msg) => {
  console.log(msg); // "Hello, Chirag!"
});

The term “callback” describes how the function is used, not a special language feature. Any function can be a callback — it’s just a function that gets called by another function instead of being called directly by you.

Synchronous Callbacks

Synchronous callbacks execute immediately, inline with the surrounding code. Array methods are the most common example:

// forEach — synchronous callback
[1, 2, 3].forEach((num) => {
  console.log(num); // 1, 2, 3 — runs immediately
});
console.log('Done'); // runs after all iterations

// map
const doubled = [1, 2, 3].map(n => n * 2); // [2, 4, 6]

// filter
const evens = [1, 2, 3, 4, 5].filter(n => n % 2 === 0); // [2, 4]

// sort
const sorted = ['banana', 'apple', 'cherry'].sort((a, b) => 
  a.localeCompare(b)
); // ['apple', 'banana', 'cherry']

With synchronous callbacks, the code after the function call waits until all callbacks have finished. There’s nothing asynchronous happening.

Asynchronous Callbacks

Asynchronous callbacks are scheduled to run later — after an I/O operation, timer, or event. The code after the function call continues immediately without waiting:

console.log('1: Before setTimeout');

setTimeout(() => {
  console.log('2: Inside callback');
}, 1000);

console.log('3: After setTimeout');

// Output:
// 1: Before setTimeout
// 3: After setTimeout
// 2: Inside callback (after 1 second)

This non-blocking behavior is what makes JavaScript efficient for web applications — it can handle user interactions, network requests, and animations without freezing the UI.

Node.js I/O Callbacks

const fs = require('fs');

fs.readFile('data.json', 'utf8', (error, data) => {
  if (error) {
    console.error('Failed to read:', error.message);
    return;
  }
  console.log('File contents:', data);
});
console.log('This runs BEFORE the file is read');

Common Callback Patterns

Completion Callbacks

function fetchUserData(userId, onComplete) {
  // Simulate API call
  setTimeout(() => {
    const user = { id: userId, name: 'Chirag', role: 'admin' };
    onComplete(user);
  }, 500);
}

fetchUserData(1, (user) => {
  console.log('Got user:', user.name);
});

Success/Error Callbacks

function loadImage(url, onSuccess, onError) {
  const img = new Image();
  img.onload = () => onSuccess(img);
  img.onerror = () => onError(new Error(`Failed to load: ${url}`));
  img.src = url;
}

loadImage(
  'photo.jpg',
  (img) => document.body.appendChild(img),
  (err) => console.error(err.message)
);

Iteratee Callbacks

function processItems(items, transform, done) {
  const results = [];
  for (const item of items) {
    results.push(transform(item));
  }
  done(results);
}

processItems(
  [1, 2, 3],
  (n) => n * 10,
  (results) => console.log(results) // [10, 20, 30]
);

The Error-First Convention

Node.js established the error-first callback pattern — the first parameter of the callback is always the error (or null if no error occurred):

function readConfig(path, callback) {
  fs.readFile(path, 'utf8', (err, data) => {
    if (err) return callback(err, null);
    
    try {
      const config = JSON.parse(data);
      callback(null, config);
    } catch (parseError) {
      callback(parseError, null);
    }
  });
}

// Usage
readConfig('config.json', (error, config) => {
  if (error) {
    console.error('Config error:', error.message);
    return;
  }
  console.log('Config loaded:', config);
});

The error-first pattern is a convention, not a language feature. Always check the error parameter first and return early if it’s present.

Callback Hell and the Pyramid of Doom

When you need to perform several async operations in sequence, callbacks nest inside callbacks, creating the infamous “pyramid of doom”:

// The pyramid of doom
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.shipmentId, (err, shipping) => {
        if (err) return handleError(err);
        displayTrackingInfo(shipping);
        // We're 4 levels deep...
      });
    });
  });
});

Problems with callback hell: code is hard to read and maintain, error handling is duplicated at every level, adding new steps means deeper nesting, and debugging nested callbacks is painful because stack traces are fragmented.

Fixing Callback Hell

1. Named Functions

function handleShipping(err, shipping) {
  if (err) return handleError(err);
  displayTrackingInfo(shipping);
}

function handleDetails(err, details) {
  if (err) return handleError(err);
  getShippingInfo(details.shipmentId, handleShipping);
}

function handleOrders(err, orders) {
  if (err) return handleError(err);
  getOrderDetails(orders[0].id, handleDetails);
}

function handleUser(err, user) {
  if (err) return handleError(err);
  getOrders(user.id, handleOrders);
}

getUser(userId, handleUser);

2. Async Library (async.js)

// Using async.waterfall
async.waterfall([
  (cb) => getUser(userId, cb),
  (user, cb) => getOrders(user.id, cb),
  (orders, cb) => getOrderDetails(orders[0].id, cb),
  (details, cb) => getShippingInfo(details.shipmentId, cb),
], (err, shipping) => {
  if (err) return handleError(err);
  displayTrackingInfo(shipping);
});

3. Promises (The Modern Solution)

getUser(userId)
  .then(user => getOrders(user.id))
  .then(orders => getOrderDetails(orders[0].id))
  .then(details => getShippingInfo(details.shipmentId))
  .then(shipping => displayTrackingInfo(shipping))
  .catch(handleError);

4. Async/Await (The Cleanest)

async function trackOrder(userId) {
  try {
    const user = await getUser(userId);
    const orders = await getOrders(user.id);
    const details = await getOrderDetails(orders[0].id);
    const shipping = await getShippingInfo(details.shipmentId);
    displayTrackingInfo(shipping);
  } catch (error) {
    handleError(error);
  }
}

Event-Based Callbacks

DOM events are the most visible use of callbacks in front-end JavaScript:

// Click handler
document.getElementById('btn').addEventListener('click', (event) => {
  console.log('Clicked!', event.target);
});

// Keyboard handler
document.addEventListener('keydown', (event) => {
  if (event.key === 'Escape') closeModal();
});

// Form submit
form.addEventListener('submit', (event) => {
  event.preventDefault();
  const data = new FormData(event.target);
  submitForm(data);
});

Event callbacks fire every time the event occurs (unlike one-shot async callbacks). Remove them when they’re no longer needed to prevent memory leaks:

function handleClick() {
  console.log('Clicked');
}
button.addEventListener('click', handleClick);
// Later:
button.removeEventListener('click', handleClick);

Timer Callbacks

// Run once after delay
const timeoutId = setTimeout(() => {
  console.log('1 second passed');
}, 1000);

// Cancel
clearTimeout(timeoutId);

// Run repeatedly
const intervalId = setInterval(() => {
  console.log('Every 2 seconds');
}, 2000);

// Stop
clearInterval(intervalId);

// requestAnimationFrame — synced to display refresh
function animate() {
  updatePosition();
  render();
  requestAnimationFrame(animate);
}
requestAnimationFrame(animate);

Array Method Callbacks

Array methods accept callbacks that define the operation for each element:

const users = [
  { name: 'Alice', age: 30, active: true },
  { name: 'Bob', age: 25, active: false },
  { name: 'Charlie', age: 35, active: true },
];

// filter + map chain
const activeNames = users
  .filter(u => u.active)
  .map(u => u.name);
// ['Alice', 'Charlie']

// reduce
const totalAge = users.reduce((sum, u) => sum + u.age, 0); // 90

// find
const bob = users.find(u => u.name === 'Bob');

// every / some
const allActive = users.every(u => u.active);  // false
const anyActive = users.some(u => u.active);    // true

// sort (mutates!)
const byAge = [...users].sort((a, b) => a.age - b.age);

The this Problem in Callbacks

Regular function callbacks lose their this context — one of the most common JavaScript bugs:

class UserList {
  constructor(users) {
    this.users = users;
  }
  
  printAll() {
    // BUG: `this` is undefined inside forEach's regular function
    this.users.forEach(function(user) {
      console.log(this.users.length); // TypeError!
    });
  }
}

// Fix 1: Arrow function (inherits `this`)
printAll() {
  this.users.forEach((user) => {
    console.log(this.users.length); // works!
  });
}

// Fix 2: bind
printAll() {
  this.users.forEach(function(user) {
    console.log(this.users.length);
  }.bind(this));
}

// Fix 3: thisArg parameter (some methods support it)
printAll() {
  this.users.forEach(function(user) {
    console.log(this.users.length);
  }, this);
}

Arrow functions are the cleanest solution and the reason they were added to the language. See our functions guide for details.

From Callbacks to Promises

You can convert any callback-based function to a Promise using a wrapper pattern called “promisification”:

// Callback-based
function getData(id, callback) {
  setTimeout(() => callback(null, { id, data: 'result' }), 100);
}

// Promisified version
function getDataAsync(id) {
  return new Promise((resolve, reject) => {
    getData(id, (error, result) => {
      if (error) reject(error);
      else resolve(result);
    });
  });
}

// Now you can use async/await
const data = await getDataAsync(42);

// Node.js has a built-in utility
const { promisify } = require('util');
const readFileAsync = promisify(fs.readFile);

Best Practices

Follow the error-first convention for Node.js-style callbacks. Always handle errors — never ignore the error parameter. Use arrow functions for short callbacks to avoid this issues. Name your callbacks for better stack traces and readability. Avoid nesting more than 2-3 levels deep — use named functions, Promises, or async/await. Return early on error to avoid else blocks. Don’t mix callbacks and Promises in the same function. For new code, prefer Promises and async/await over raw callbacks.

Callbacks taught JavaScript how to be asynchronous, but Promises and async/await offer a much cleaner model. You’ll learn about those in upcoming lessons on the JavaScript Roadmap.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *