APIs & AsyncIntermediate10 min08 / 8

Async in Node

Learn how Node.js handles asynchronous work — from old-school callbacks all the way to clean async/await — and how to keep your code from turning into a tangled mess.

Here is something that trips up almost every developer new to Node.js: JavaScript runs on a single thread. That means it can only do one thing at a time. Yet Node.js servers cheerfully handle thousands of simultaneous requests — file reads, database queries, HTTP calls — without breaking a sweat. How? The answer is asynchronous programming, and understanding it will unlock the full power of Node.js for you.

In this lesson we will travel through the history of async patterns in JavaScript: from humble callbacks, through Promises, and finally to the elegant async/await syntax used in all modern Node.js code. We will also cover error handling and running tasks in parallel. By the end, writing async code will feel natural.

#Why Async Exists: The Single-Thread Problem

Think of it like

Node.js is like a great waiter

Imagine a restaurant with one waiter. A bad waiter takes your order, walks to the kitchen, and stands there staring at the grill until your food is ready — blocking everyone else. A great waiter takes your order, drops it at the kitchen window, then immediately turns around and takes the next table's order. When the kitchen calls "order up!", the waiter comes back to deliver it.

Node.js is that great waiter. It kicks off slow work (reading a file, calling an API) and moves on immediately. When the slow work finishes, Node gets a notification and handles the result. This is the event loop in action.

If Node.js used the "bad waiter" model — pausing and waiting for every slow operation — a single slow database query would freeze the entire server for every user. Async patterns let Node.js hand slow work off to the operating system, stay responsive, and pick up results when they arrive.

#Era 1: Callbacks

The original async pattern in Node.js is the callback: a function you pass into another function, to be called when the work is done. Node's built-in APIs were all designed this way.

The convention is error-first callbacks — the first argument to your callback is always an error (or null if everything went fine).

fs.readFile is async — the callback fires later, after the OS delivers the file contents.
const fs = require('fs');

fs.readFile('hello.txt', 'utf8', function (err, data) {
  if (err) {
    console.error('Something went wrong:', err.message);
    return;
  }
  console.log('File contents:', data);
});

console.log('This line runs BEFORE the file is read!');
Common mistake

Callback Hell

Callbacks work fine for one level deep. The problem comes when you need to chain several async operations — read a file, then parse its contents, then save a result. Each step nests inside the previous callback, creating deeply indented code nicknamed "callback hell" or "the pyramid of doom".

``js readFile('config.txt', (err, config) => { parseConfig(config, (err, settings) => { fetchUser(settings.userId, (err, user) => { saveUser(user, (err) => { // By now you've lost track of which err is which }); }); }); }); ``

This code is hard to read, hard to debug, and a maintenance nightmare. Promises were invented to solve exactly this.

#Era 2: Promises

A Promise is an object that represents a value that will be available at some point in the future — or an error if something goes wrong. Instead of passing a callback into a function, the function returns a Promise, and you attach handlers to it with .then() and .catch().

This transforms the pyramid into a flat, readable chain.

fs.promises gives Promise-based versions of all file system methods. One .catch() handles errors from the entire chain.
const fs = require('fs').promises;

fs.readFile('hello.txt', 'utf8')
  .then((data) => {
    console.log('File contents:', data);
    return data.toUpperCase();
  })
  .then((upper) => {
    console.log('Uppercased:', upper);
  })
  .catch((err) => {
    console.error('Error:', err.message);
  });

Notice two things: the chain reads top-to-bottom like synchronous code, and a single .catch() at the end handles any error that occurs anywhere in the chain. That is already a huge improvement over callbacks.

You can also create your own Promises when wrapping older callback-based APIs:

new Promise takes an executor function with resolve and reject. Call resolve() when done, reject() on failure.
function wait(ms) {
  return new Promise((resolve) => {
    setTimeout(resolve, ms);
  });
}

wait(1000).then(() => console.log('1 second passed!'));

#Era 3: async/await — Promises with a Superpower

async/await is not a replacement for Promises — it is syntax sugar on top of Promises. Under the hood, everything is still a Promise. But await lets you pause inside a function until a Promise settles, making async code look and feel synchronous.

Rules: - Mark the function with async. - Use await before any expression that returns a Promise. - An async function always returns a Promise itself.

Compare this to the .then() chain above — same behaviour, but reads like straight-line synchronous code.
const fs = require('fs').promises;

async function readAndPrint() {
  const data = await fs.readFile('hello.txt', 'utf8');
  const upper = data.toUpperCase();
  console.log('Uppercased:', upper);
}

readAndPrint();

#Error Handling with try/catch

With async/await, error handling uses the familiar try/catch blocks you already know from synchronous code. Wrap the awaited calls in a try block and handle failures in catch.

The finally block always runs — useful for cleanup like closing database connections.
const fs = require('fs').promises;

async function readSafely(filename) {
  try {
    const data = await fs.readFile(filename, 'utf8');
    console.log('Contents:', data);
  } catch (err) {
    console.error('Could not read file:', err.message);
  } finally {
    console.log('Done attempting to read.');
  }
}

readSafely('missing.txt');
Watch out

Forgetting to await is a silent killer

If you forget await, the function returns a Promise object instead of its resolved value — and no error is thrown. Your code will silently produce wrong results.

```js // BAD: data is a Promise object, not a string const data = fs.readFile('hello.txt', 'utf8'); console.log(data); // [object Promise]

// GOOD const data = await fs.readFile('hello.txt', 'utf8'); ```

If you ever see [object Promise] in your output, this is almost always the cause.

#Running Tasks in Parallel with Promise.all

Sometimes you have several independent async tasks and you want to run them at the same time instead of one after another. Promise.all takes an array of Promises and returns a single Promise that resolves when all of them finish.

Compare the two approaches:

Promise.all is a huge win when tasks don't depend on each other. Use destructuring to grab each result cleanly.
async function sequential() {
  // Waits 1s, then waits another 1s — total: ~2 seconds
  const a = await fetchUser(1);   // 1 second
  const b = await fetchUser(2);   // 1 second
  return [a, b];
}

async function parallel() {
  // Both fire at the same time — total: ~1 second
  const [a, b] = await Promise.all([
    fetchUser(1),
    fetchUser(2)
  ]);
  return [a, b];
}

One important caveat: if any Promise in the array rejects, Promise.all immediately rejects with that error. Wrap it in try/catch to handle failures gracefully.

For cases where you want all results even if some fail, check out Promise.allSettled — it waits for every Promise to either resolve or reject and gives you a status report for each one.

Quick check

You `await` two async operations one after the other (sequentially) and each takes 500ms. You then refactor to use `Promise.all`. Roughly how long will the parallel version take?

Tip

Quick mental model for choosing a pattern

  • Callbacks: only when using a legacy library that forces them (e.g. some older npm packages).
  • Promises with .then(): handy for short chains or when you need fine-grained control.
  • async/await: your default for all new code — clearest, easiest to debug.
  • Promise.all: whenever two or more independent async tasks can safely run at the same time.

Key takeaways

  • Node.js uses an event loop to handle many operations concurrently on a single thread — async patterns are what make this possible.
  • Callbacks were the original async tool, but nesting them creates hard-to-read 'callback hell'.
  • Promises flatten async chains and centralise error handling with a single .catch().
  • async/await is syntactic sugar over Promises — it makes async code read like synchronous code and pairs naturally with try/catch.
  • Promise.all runs independent tasks in parallel, dramatically cutting total wait time when tasks don't depend on each other.
Practice challenges
Test yourself · earn XP
0/4
Predict the output#1

The lesson shows that fs.readFile is asynchronous. What does this program print?

predict-output
const fs = require('fs');

fs.readFile('hello.txt', 'utf8', function (err, data) {
  console.log('Inside callback');
});

console.log('After readFile');
Fix the bug#2

This code has a bug — what's wrong?

fix-bug
const fs = require('fs').promises;

async function readAndPrint() {
  const data = fs.readFile('hello.txt', 'utf8');
  console.log('Contents:', data);
}

readAndPrint();
Fill in the blank#3

Complete this async function so it pauses until the file is read and handles any error, matching the lesson's try/catch pattern.

async function readSafely(filename) {
  try {
    const data =  fs.readFile(filename, 'utf8');
    console.log('Contents:', data);
  }  (err) {
    console.error('Could not read file:', err.message);
  }
}
Reorder the lines#4

Arrange these lines into a working async function that fetches a user's name and score in parallel with Promise.all, as taught in the lesson.

1
async function loadUser() {
2
  console.log(name, score);
3
  const [name, score] = await Promise.all([
4
}
5
  ]);
6
    fetchScore()
7
    fetchName(),
Your turn
Practice exercise

Write an async function called loadUserData that does three things in parallel using Promise.all: fetches a user name (simulated with a 500ms delay returning 'Alice'), fetches their score (300ms delay returning 42), and fetches their rank (400ms delay returning 'Gold'). Log all three results. Wrap everything in try/catch so errors are handled gracefully.

Try it yourself — a starting point to build on:

starter.js
function delay(ms, value) {
  return new Promise((resolve) => setTimeout(() => resolve(value), ms));
}

async function loadUserData() {
  try {
    // TODO: use Promise.all to fetch name, score, and rank in parallel
    // delay(500, 'Alice') -> name
    // delay(300, 42)      -> score
    // delay(400, 'Gold')  -> rank

    console.log(`Name: ${name}, Score: ${score}, Rank: ${rank}`);
  } catch (err) {
    console.error('Failed to load user data:', err.message);
  }
}

loadUserData();