Skip to main content

Callbacks and Asynchronous JavaScript

Published: December 18, 2025 Updated: August 30, 2026 Larry Qu 6 min read

JavaScript is single-threaded — it executes one statement at a time, in order. But many operations take time: reading a file, querying a database, fetching data from an API. If JavaScript waited for each of these to finish before moving on, a web page would freeze every time it made a network request.

The solution is asynchronous execution combined with callbacks. Callbacks are how JavaScript has always handled this, and understanding them is foundational — even if you now write async/await every day, callbacks are what run underneath it.

The Event Loop in One Paragraph

JavaScript has a call stack and a task queue. Synchronous code runs directly on the stack. When an async operation completes (a timer fires, an HTTP response arrives), a callback is placed in the task queue. The event loop picks tasks from the queue and puts them on the call stack only when the stack is empty. This is why JavaScript can be non-blocking despite being single-threaded.

The practical consequence:

console.log('start');

setTimeout(() => {
  console.log('timeout');  // placed in task queue, runs after synchronous code finishes
}, 0);

console.log('end');

// Output order: start, end, timeout
// Even with 0ms delay, setTimeout runs after the current call stack clears

What a Callback Is

A callback is simply a function passed as an argument to another function, intended to be called at some later point — either after an async operation completes, or when a certain event occurs.

// setTimeout is the simplest example: call this function after N ms
setTimeout(function() {
  console.log('This runs after 1 second');
}, 1000);

// Array methods also use callbacks — synchronous callbacks
const numbers = [1, 2, 3, 4, 5];
const doubled = numbers.map(function(n) { return n * 2; });
// map calls your callback for each element, synchronously

Synchronous callbacks (like array methods) execute immediately as part of the current call. Asynchronous callbacks execute later, after the event loop places them back on the stack.

Error-First Callbacks: Node.js Convention

Node.js established a convention for async callbacks: the first argument is always an error (or null if there is none), and subsequent arguments are the result data. This is called the error-first or Node-style callback pattern:

const fs = require('fs');

// error-first: callback(err, data)
fs.readFile('config.json', 'utf8', function(err, data) {
  if (err) {
    // Handle the error before touching data
    console.error('Could not read file:', err.message);
    return;  // ← critical: return here so you don't continue with undefined data
  }
  const config = JSON.parse(data);
  console.log(config.port);
});

The early return after handling the error is important. Forgetting it and continuing to use data when it’s undefined is a common source of confusing bugs.

When you write your own async functions that accept callbacks, follow the same convention:

function fetchUser(userId, callback) {
  const user = db.users.find(u => u.id === userId);

  if (!user) {
    // Error first — call with an Error, no data
    callback(new Error(`User ${userId} not found`));
    return;
  }

  // Success — null error, then data
  callback(null, user);
}

fetchUser(42, function(err, user) {
  if (err) {
    console.error(err.message);
    return;
  }
  console.log(`Found: ${user.name}`);
});

Callback Hell: Why it Happens

Callbacks become painful when you need operations that depend on each other’s results. Each step needs the previous step’s result, so you nest callbacks inside callbacks — the “pyramid of doom”:

// Getting a user's most recent order with all items — three dependent async steps
getUser(userId, function(err, user) {
  if (err) { handleError(err); return; }

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

    getOrderItems(orders[0].id, function(err, items) {
      if (err) { handleError(err); return; }

      console.log(items);
      // If you need a fourth step, it nests here too
    });
  });
});

The problems with deeply nested callbacks:

  • Readability degrades fast — the logic is buried under indentation
  • Error handling must be duplicated at every level
  • Control flow is hard to follow — it’s non-linear
  • Refactoring is painful — changing the shape requires rewriting the nesting

Flattening with Named Functions

The simplest fix for callback hell doesn’t require Promises — just name your callbacks instead of inlining them:

// Each step is a named function at the top level
function handleItems(err, items) {
  if (err) { handleError(err); return; }
  console.log(items);
}

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

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

getUser(userId, handleUser);

This is flat, readable, and debuggable — each function has a name that appears in stack traces.

Migrating to Promises

Most modern APIs return Promises. When you’re working with a callback-based API you can’t change, wrap it once with util.promisify (Node.js) or manually:

const { promisify } = require('util');
const fs = require('fs');

// Convert callback-based readFile to a Promise-returning function
const readFileAsync = promisify(fs.readFile);

// Now use it with async/await
async function loadConfig() {
  const data = await readFileAsync('config.json', 'utf8');
  return JSON.parse(data);
}

// Or manually wrap any callback function
function fetchUserAsync(userId) {
  return new Promise((resolve, reject) => {
    fetchUser(userId, function(err, user) {
      if (err) reject(err);
      else resolve(user);
    });
  });
}

The three-step async operation in callback form, rewritten with async/await:

async function getUserOrderItems(userId) {
  const user   = await fetchUserAsync(userId);
  const orders = await getOrdersAsync(user.id);
  const items  = await getOrderItemsAsync(orders[0].id);
  return items;
}

This is the same sequential logic as the nested callback version, but now it’s flat and the error handling happens in one try/catch.

Callbacks Still Matter

Even though async/await is the preferred style for new code, callbacks still appear in many contexts:

  • Event listeners: element.addEventListener('click', callback) — the browser calls your function when the event fires
  • Array methods: map, filter, reduce, forEach — synchronous callbacks that transform data
  • Stream processing: Node.js streams emit data events with callbacks
  • Third-party libraries: many older libraries and some newer ones use the callback pattern
// Event listeners are callbacks that fire repeatedly, not just once
document.getElementById('search').addEventListener('input', function(event) {
  console.log('User typed:', event.target.value);
});

// Array method callbacks run synchronously — useful distinction
const names = ['alice', 'bob', 'carol'];
const upper = names.map(name => name.toUpperCase());  // synchronous, no awaiting needed

The rule of thumb: if a callback fires exactly once and represents completion of an async operation, a Promise is almost always cleaner. If it fires on events (user interaction, data stream chunks) or is used to transform data synchronously, callbacks are still the right tool.

setTimeout and setInterval

These built-in timer functions are a common source of first contact with async callbacks:

// Execute once after a delay
const timerId = setTimeout(function() {
  console.log('Runs once after 2 seconds');
}, 2000);

// Cancel before it fires
clearTimeout(timerId);

// Execute repeatedly at an interval
let count = 0;
const intervalId = setInterval(function() {
  count++;
  console.log(`Tick ${count}`);
  if (count >= 5) clearInterval(intervalId);  // always clear intervals when done
}, 1000);

Always store the ID returned by setInterval and call clearInterval when finished. An interval that’s never cleared is a memory leak — it runs forever even after the component or page it belongs to is gone.

Summary

Callbacks are JavaScript’s original async mechanism. They work well for simple cases and remain appropriate for event listeners and synchronous data transformations. Their weakness is composability — sequential async steps become deeply nested, and error handling becomes repetitive.

The mental model: callbacks tell JavaScript “when this async thing is done, run this function.” The event loop manages when that happens. Promises and async/await are built on the same mechanism, with better syntax for chaining and error handling.

When to use each:

  • Callbacks: event listeners, array methods, simple one-off async operations with legacy APIs
  • Promises: wrapping callback APIs, Promise.all for parallel operations
  • async/await: sequential async logic, readable error handling with try/catch

Resources

Comments

👍 Was this article helpful?