Skip to main content

Command Palette

Search for a command to run...

From Callbacks to Async/Await: The Complete Guide to Async Code in Node.js

Updated
14 min read
S
Hi, I'm Shreshtha. I'm passionate about technology, problem-solving, and understanding how things work under the hood. Currently exploring JavaScript, Node.js, and backend development while documenting what I learn along the way. I love understanding how technology works behind the scenes and turning that learning into simple, practical explanations. I enjoy breaking down complex concepts into simple explanations and sharing insights from my learning journey. Through these blog, I write about programming, backend development, and ideas around technology and innovation. Always learning, always building.

If you have spent any time with Node.js, you have come across words like "callback", "Promise", and async/await in nearly every tutorial and documentation page you find. These are not just buzzwords — they are the fundamental building blocks of how Node.js handles work. Understanding why they exist, how they differ, and when to use each one is one of the most important shifts you can make as a backend developer.

This article walks you through the full journey in one place: from the reason async code became necessary, through the messy world of nested callbacks, into the cleaner world of Promises, and finally to the async/await syntax that makes it all readable.


Why Does Async Code Exist in Node.js?

Node.js runs on a single thread. There is exactly one call stack, one thread of execution. Compared to languages like Java or Python where each request can be handled by a separate thread, Node.js takes a fundamentally different approach.

The question is: if there is only one thread, how does a Node.js server handle thousands of simultaneous requests without freezing every time it needs to read a file or query a database?

The answer lies in the event loop and non-blocking I/O.

When your code asks Node.js to do something that involves waiting — reading a file from disk, querying a database, making an HTTP request — Node.js does not sit there frozen, waiting for that operation to complete. Instead, it hands that work off to the operating system (via libuv under the hood), registers a function to be called once the work is done, and immediately moves on to handle other things.

This design is what makes Node.js highly efficient at I/O-heavy tasks. The registered function — the one that runs when the work is done — is the callback. Everything else in this article is built on top of that foundational idea.


The Scenario: Reading a Config File

To make this concrete throughout the article, we will use a single running example: reading the contents of a configuration file and using its data.

In a synchronous world, you would write this:

const fs = require("fs");

const data = fs.readFileSync("config.json", "utf8");
const config = JSON.parse(data);
console.log("Port:", config.port);

This works, but readFileSync blocks the entire thread. While Node.js waits for the disk to return the file, nothing else can run. For scripts this is fine. For a server handling multiple users, it is a critical bottleneck.

Everything that follows is a different solution to this exact problem — how to read that file without blocking everything else.


Part 1: Callback-Based Async

How Callbacks Work

A callback is a function you pass as an argument to an async operation. Node.js will call it when the operation completes — either successfully or with an error.

const fs = require("fs");

fs.readFile("config.json", "utf8", function (error, data) {
  if (error) {
    console.log("Something went wrong:", error.message);
    return;
  }

  const config = JSON.parse(data);
  console.log("Port:", config.port);
});

console.log("This prints first — before the file is read.");

Node.js calls fs.readFile, hands the I/O work off to the OS, and immediately moves to the next line. That last console.log runs right away. Later, once the file system returns the data, Node.js picks up the callback, puts it on the call stack, and runs it.

The Error-First Convention

The first argument to every Node.js callback is always the error. This is called the error-first callback pattern. If something goes wrong, error contains an Error object. If everything succeeded, error is null and the second argument contains the result. This means every callback must check for errors before doing anything with the data.

Step-by-Step Execution Flow

Here is what happens in order when fs.readFile is called with a callback:

fs.readFile is invoked. Node.js registers the callback and kicks off the I/O operation in the background. Control returns immediately to the rest of your code. When the file system completes the read, the callback is queued in the event loop. On the next available tick, Node.js picks it up and runs it. Inside the callback, you first check for an error, then work with the data.


The Problem: Callback Hell

Real applications rarely do just one async operation. You read a file, then use that data to query a database, then take the query result and write to another file, then log the result to an audit service. Each step depends on the previous one completing successfully.

With callbacks, each step must live inside the previous step's callback. The result is code that is notoriously hard to read and maintain:

const fs = require("fs");

fs.readFile("config.json", "utf8", function (error, configData) {
  if (error) {
    return;
  }

  const config = JSON.parse(configData);

  fs.readFile(config.userFilePath, "utf8", function (error, userData) {
    if (error) {
      return;
    }

    const user = JSON.parse(userData);

    fs.writeFile("output.json", JSON.stringify(user), function (error) {
      if (error) {
        return;
      }

      fs.readFile("output.json", "utf8", function (error, result) {
        if (error) {
          return;
        }

        console.log("Final result:", result);
      });
    });
  });
});

This pattern is so common it has a name: callback hell, sometimes called the pyramid of doom because of the triangular shape the indentation takes.

The problems here are not just aesthetic:

Error handling is repetitive and fragile. Every single callback must manually check for an error. If you forget one check, your application will silently continue with bad data or crash unexpectedly.

The flow of logic is inside-out. The first operation is at the outermost level but the final result is buried at the innermost level. You read code top-to-bottom but the sequence is outside-to-inside.

Refactoring is painful. Adding a step between step 2 and step 3 means restructuring the entire nesting structure.

You cannot easily return values. The result of an async operation only exists inside the callback. You cannot use return to get it out.


Part 2: Promise-Based Async

Promises were introduced to solve these exact problems. A Promise is an object that represents the eventual completion (or failure) of an asynchronous operation and its resulting value.

Instead of passing a callback function into an async operation, a function that returns a Promise gives you an object you can hold onto, chain, and reason about.

The Promise Lifecycle

A Promise always exists in one of three states:

Pending — the initial state. The async operation has started but has not yet completed.

Fulfilled — the operation completed successfully. The Promise now holds a resolved value, and any .then() handlers will be called with that value.

Rejected — the operation failed. The Promise holds an error reason, and any .catch() handlers will be called with it.

Once a Promise moves from Pending to either Fulfilled or Rejected, it is settled and that state never changes.

Writing Promise-Based Code

The modern fs.promises API in Node.js exposes Promise-returning versions of all the file system methods. Here is the same file read operation rewritten with Promises:

const fs = require("fs").promises;

fs.readFile("config.json", "utf8")
  .then(function (data) {
    const config = JSON.parse(data);
    console.log("Port:", config.port);
  })
  .catch(function (error) {
    console.log("Error reading file:", error.message);
  });

readFile returns a Promise object. You attach a .then() handler that receives the data once the file is ready. You attach a .catch() handler that receives any error. The logic is flat, readable left-to-right and top-to-bottom.

Promise Chaining

Every .then() returns a new Promise. When you return a value from inside .then(), it becomes the resolved value of the next Promise in the chain. When you return a Promise from .then(), the chain waits for that inner Promise to settle before continuing. This is what makes flat structure possible.

The deeply nested callback example, rewritten with Promises:

const fs = require("fs").promises;

fs.readFile("config.json", "utf8")
  .then(function (configData) {
    const config = JSON.parse(configData);
    return fs.readFile(config.userFilePath, "utf8");
  })
  .then(function (userData) {
    const user = JSON.parse(userData);
    return fs.writeFile("output.json", JSON.stringify(user));
  })
  .then(function () {
    return fs.readFile("output.json", "utf8");
  })
  .then(function (result) {
    console.log("Final result:", result);
  })
  .catch(function (error) {
    console.log("Something went wrong:", error.message);
  });

The transformation is dramatic. Each step is a separate .then() block at the same indentation level. A single .catch() at the bottom handles any error that occurs at any step in the chain.

Benefits of Promises

Chaining instead of nesting. Steps are expressed as a flat sequence, not a pyramid.

Centralized error handling. A single .catch() at the end of a chain catches any error from any step.

Composability. Promises can be combined using utilities like Promise.all() to run multiple async operations in parallel, or Promise.race() to get the result of whichever one finishes first.

Cleaner mental model. A Promise is just an object. You can pass it around, store it in a variable, return it from a function.


Part 3: Async/Await

async/await is the final evolution. It is not a replacement for Promises — it is built directly on top of them. What it gives you is a way to write async code that looks and reads almost exactly like synchronous code.

The async Keyword

The async keyword is placed before a function declaration. Two things happen when you mark a function with async. First, the function always returns a Promise, regardless of what you return inside it. Second, the function body gains the ability to use the await keyword.

async function readConfig() {
  return "config data";
}

You can use async with any kind of function — regular declarations, function expressions, and arrow functions.

The await Keyword

await can only be used inside an async function. When you place await in front of a Promise, JavaScript pauses the execution of that function until the Promise settles — then resumes it with the resolved value.

Here is the file reading example, now written with async/await:

const fs = require("fs").promises;

async function readConfig() {
  const data = await fs.readFile("config.json", "utf8");
  const config = JSON.parse(data);
  console.log("Port:", config.port);
}

readConfig();

Read that code top to bottom. It looks like synchronous code. await pauses readConfig until the file read Promise resolves. Once it does, the resolved value is stored in data. Then JSON.parse runs. Then console.log runs.

How await Actually Works

The critical thing to understand is that await does not block the entire Node.js process. It only pauses the execution of the current async function. While readConfig is waiting for the file to be read, the event loop is still running, handling other tasks. The rest of the program is not frozen — only this function's execution is suspended until its awaited operation completes.

Error Handling with try/catch

With async/await, you use the standard JavaScript try/catch block — the same mechanism you use to catch synchronous errors.

const fs = require("fs").promises;

async function readConfig() {
  try {
    const data = await fs.readFile("config.json", "utf8");
    const config = JSON.parse(data);
    console.log("Port:", config.port);
  } catch (error) {
    console.log("Something went wrong:", error.message);
  }
}

readConfig();

If fs.readFile rejects, the error is thrown from the await expression and caught by the catch block. If JSON.parse throws a syntax error, the same catch block handles it. One try/catch covers both synchronous and asynchronous errors in the same place — something neither callbacks nor Promise chains could do.

Multiple Awaits in Sequence

The same deeply nested callback example, now written with async/await:

const fs = require("fs").promises;

async function processFiles() {
  try {
    const configData = await fs.readFile("config.json", "utf8");
    const config = JSON.parse(configData);

    const userData = await fs.readFile(config.userFilePath, "utf8");
    const user = JSON.parse(userData);

    await fs.writeFile("output.json", JSON.stringify(user));

    const result = await fs.readFile("output.json", "utf8");
    console.log("Final result:", result);
  } catch (error) {
    console.log("Something went wrong:", error.message);
  }
}

processFiles();

Each await line reads like a normal statement. The sequence is obvious. A single try/catch wraps everything. No nesting, no indentation pyramid, no manual error argument checking.


Running Operations in Parallel with Promise.all

There is one important situation where sequential await is not the right tool: when you want to run multiple independent async operations at the same time, without waiting for each one to finish before starting the next.

Consider this:

const fs = require("fs").promises;

async function readBothFiles() {
  const file1 = await fs.readFile("users.json", "utf8");
  const file2 = await fs.readFile("products.json", "utf8");
  return { file1, file2 };
}

This works, but it is slower than it needs to be. file2 does not start being read until file1 has fully finished. Since the two reads are completely independent, there is no reason to wait.

Promise.all runs multiple Promises concurrently and waits for all of them to finish:

const fs = require("fs").promises;

async function readBothFiles() {
  const [file1, file2] = await Promise.all([
    fs.readFile("users.json", "utf8"),
    fs.readFile("products.json", "utf8"),
  ]);
  return { file1, file2 };
}

Both readFile calls start at the same time. Promise.all returns a single Promise that resolves when both have completed, giving you an array of results in the same order as the input. If any one of the Promises rejects, Promise.all immediately rejects with that error.

The rule: use sequential await when each step depends on the previous one's result. Use Promise.all when steps are independent.


The Complete Comparison

Here is the same task — reading a file, parsing it, logging a value — written three ways.

Callbacks:

const fs = require("fs");

fs.readFile("config.json", "utf8", function (error, data) {
  if (error) {
    console.log("Failed:", error.message);
    return;
  }
  const config = JSON.parse(data);
  console.log("Port:", config.port);
});

Promises:

const fs = require("fs").promises;

fs.readFile("config.json", "utf8")
  .then(function (data) {
    const config = JSON.parse(data);
    console.log("Port:", config.port);
  })
  .catch(function (error) {
    console.log("Failed:", error.message);
  });

Async/Await:

const fs = require("fs").promises;

async function readConfig() {
  try {
    const data = await fs.readFile("config.json", "utf8");
    const config = JSON.parse(data);
    console.log("Port:", config.port);
  } catch (error) {
    console.log("Failed:", error.message);
  }
}

readConfig();

All three produce the same result. The async/await version is the most readable — not because it is magic, but because it hides the machinery of Promises while keeping all of their benefits underneath.


What async/await Does Not Change

It is worth being clear about what async/await does not do.

It does not make your code synchronous. The event loop still runs. Other tasks still get processed while your function is paused on an await. Node.js's non-blocking nature is fully preserved.

It does not eliminate Promises. Every await expression is still just a Promise under the hood. async/await is syntax sugar — a cleaner way to write the same Promise-based code.

It does not automatically handle errors. If you forget try/catch, an error thrown inside an async function will result in a rejected Promise returned by that function. If nothing handles that rejected Promise, you will get an unhandled rejection warning.


A Note on Top-Level Await

In modern Node.js (v14.8+, with ES modules), you can use await at the top level of a module — outside of any async function:

import { readFile } from "fs/promises";

const data = await readFile("config.json", "utf8");
console.log(data);

This is called top-level await and it works only in ES modules (files using import/export syntax, or .mjs extension). In CommonJS files using require, you still need to wrap everything inside an async function.


Summary

Here is the progression in one table:

Approach Nesting Error handling Readability
Callbacks Deep pyramid Per-callback, manual Hard to follow
Promises Flat .then() chain Single .catch() Better
Async/Await Flat, sequential try/catch Closest to sync code

Each layer is built on the one before it. Callbacks are the foundation — Promises are built on the callback model, and async/await is built directly on Promises. Understanding the foundation makes everything above it click into place.

The natural next step is applying all of this in real server code — how Express route handlers work with async/await, how to avoid unhandled rejections in Express middleware, and how to structure async operations across service layers.


Written as part of the Web Dev Deep Dive series on webdevdeepdive.hashnode.dev

4 views

Web Development

Part 5 of 7

📌 Description This series documents my journey into backend development, where I break down concepts not just by what they do, but how and why they work under the hood. Instead of treating backend as a black box, I focus on: Understanding internal workings (like how servers handle requests, data flow, etc.) Connecting concepts to real-world systems and analogies Writing and analyzing code step-by-step Each article is designed to take you from confusion to clarity — whether it's HTTP methods, APIs, server architecture, or deeper backend fundamentals. 🎯 What You’ll Learn . Core backend concepts explained intuitively . How things actually work behind the scenes . Practical code implementations with breakdowns . Real-world parallels to simplify complex ideas 🚀 Who This Is For Beginners starting backend development Frontend developers curious about what happens “behind the API” Anyone who wants deeper clarity instead of surface-level tutorials 📖 Approach I’m learning and building in public — which means this series is honest, evolving, and focused on true understanding rather than memorization.

Up next

Sessions, Cookies, and JWT: The Complete Guide to Web Authentication

Every time you log into a website and come back the next day without being asked to log in again, something behind the scenes is keeping track of who you are. That "something" is authentication state