Skip to main content

Command Palette

Search for a command to run...

Async Code in Node.js: Callbacks and Promises

Updated
7 min read

If you are just starting with Node.js, you’ve likely heard the term "Asynchronous" (or async). It sounds fancy, but it is actually the secret sauce that makes Node.js so fast.

In this guide, we’ll break down why we need it and how to master the two most common ways to handle it: Callbacks and Promises.

Why does async code even exist?

Imagine you walk into a coffee shop and place your order. The barista doesn't freeze in place, staring at the espresso machine until your drink is ready — they call your name when it's done, and in the meantime they take everyone else's orders too.

Node.js works exactly the same way. It runs on a single thread, meaning it can only do one thing at a time. If it had to wait — fully stopped — every time it read a file from disk or fetched data from a database, your entire program would grind to a halt. Nobody else's request would be processed. That's terrible.

Key idea

Asynchronous (async) code lets Node.js start a slow task (like reading a file), move on to other work, and come back to handle the result when it's ready — without blocking everything else.

Reading a file is the classic example. Let's use it throughout this post. When you ask Node.js to read a file, it hands the job to the operating system and immediately moves on. When the OS finishes, it taps Node.js on the shoulder: "Hey, here's your file content."

The question is: how do you tell Node.js what to do when that tap on the shoulder arrives? That's where callbacks and promises come in.

Callbacks — the original approach

A callback is just a function you hand to another function, saying: "When you're done, call this." That's literally it. You're passing your "what to do next" instructions as a piece of code.

Here's the most common example in Node.js — reading a file:

const fs = require('fs');

// Start reading the file — don't wait around.
// When it's done, call the function we pass in.
fs.readFile('hello.txt', 'utf8', function(err, data) {
  // This function IS the callback.
  // Node.js calls it once the file is ready.

  if (err) {
    console.log('Something went wrong:', err);
    return;
  }

  console.log('File contents:', data);
});

// This runs IMMEDIATELY — before the file is even read!
console.log('Started reading the file...');

Notice something surprising: the last console.log prints before the file content does, even though it appears later in the code. That's async in action — Node.js fires off the file read, then immediately continues to the next line.

Step-by-step: what actually happens

The two important rules of the Node.js callback pattern:

  1. Error first. The callback's first argument is always an error object (err). If nothing went wrong, it's null. Always check it.

  2. Data second. The actual result — your file content, your database rows — comes in the second argument.

The problem: Callback Hell

Callbacks work fine for a single operation. But real programs rarely do just one thing. You often need to do things in sequence — read a config file, then connect to a database using that config, then run a query, then write the result to a log file.

With callbacks, each step has to live inside the previous step's callback. This creates nesting — and it spirals out of control fast.

example for callback hell — nested callbacks

fs.readFile('config.json', 'utf8', function(err, config) {
  if (err) { return console.log('Error reading config'); }

  db.connect(config, function(err, connection) {
    if (err) { return console.log('Error connecting'); }

    connection.query('SELECT * FROM users', function(err, rows) {
      if (err) { return console.log('Error querying'); }

      fs.writeFile('log.txt', rows, function(err) {
        if (err) { return console.log('Error writing'); }

        console.log('All done!'); // 5 levels deep 😱
      });
    });
  });
});

The problem

This "pyramid of doom" is hard to read, hard to debug, and every single step needs its own error check copied and pasted. Add one more step and you're six levels deep. This is officially calledCallback Hell.

Promises — the better way

A Promise is an object that represents a value you don't have yet — but will have eventually. Think of it like a ticket at a restaurant. They give you the ticket now; you'll get your food later. You can plan around having the ticket without knowing exactly when the food arrives.

A Promise is always in one of three states:

Now here's how you use a Promise. The fs/promises module gives you a version of readFile that returns a Promise instead of using a callback.

Reading a file with a promise:

const fs = require('fs/promises'); // The promise-based version

fs.readFile('hello.txt', 'utf8')
  .then(function(data) {
    // This runs when it succeeds ✓
    console.log('File contents:', data);
  })
  .catch(function(err) {
    // This runs if something goes wrong ✗
    console.log('Error:', err);
  });

Already cleaner — one .then() for success, one .catch() for all errors. But the real superpower is chaining. Each .then() returns a new Promise, so you can chain steps in a flat, readable line:

Promise chaining — no pyramid of doom

fs.readFile('config.json', 'utf8')
  .then(config => db.connect(config))
  .then(connection => connection.query('SELECT * FROM users'))
  .then(rows => fs.writeFile('log.txt', rows))
  .then(() => console.log('All done!'))
  .catch(err => console.log('Something failed:', err));
  // One .catch() handles errors from ALL steps above ✓

Much better

Same four steps as the callback version — but flat, readable, and with a single error handler catching problems from any step. This is the power of Promise chaining.

Callbacks vs Promises — at a glance

Callbacks

  • Gets deeply nested fast

  • Error handling repeated at every step

  • Hard to read top-to-bottom

  • Hard to debug

Promises

  • Stays flat with chaining

  • One .catch() handles all errors

  • Reads like a to-do list

  • Easier to trace and debug

Bonus: async/await — Promises, but even nicer

Promises are great, but there's a syntax sugar on top of them called async/await that makes your code look almost like normal, synchronous code — while still being fully async underneath.

async/await — the modern way (still uses promises!)

async function doEverything() {
  try {
    const config     = await fs.readFile('config.json', 'utf8');
    const connection = await db.connect(config);
    const rows       = await connection.query('SELECT * FROM users');
    await fs.writeFile('log.txt', rows);
    console.log('All done!');
  } catch (err) {
    console.log('Something failed:', err);
  }
}

doEverything();

Rememberasync/await is not a replacement for Promises — it's built on top of them. Every await is just a nicer way to write .then(). Under the hood, it's all Promises.

Quick recap

Async code exists because Node.js runs on one thread and can't afford to sit and wait for slow operations like file reads or database calls.

Callbacks were the first solution — you pass a function to be called when the work is done. They work, but they nest badly and make error handling messy.

Promises solve callback hell by returning an object you can chain with .then() and catch all errors with a single .catch().

async/await is modern Promise syntax that makes async code look like plain, readable, step-by-step instructions.

Where to go next

Try writing a small Node.js script that reads a text file and prints its contents. Start with the callback version, then rewrite it using Promises, then try async/await. Seeing the three styles side by side will make everything click.

HaPPy CoDiNg