Node.js Async Patterns

Master asynchronous code in Node.js: error-first callbacks, promises, async and await, running tasks in parallel with Promise.all and allSettled, limiting concurrency, timeouts with AbortSignal, and converting callback APIs with promisify.

Lesson 8 of 43Beginner18 min read

Almost everything interesting a Node.js program does takes time: reading files, querying a database, calling an API, waiting for a timer. Because Node runs your JavaScript on a single thread, it never waits for these operations to finish. Instead it starts them, carries on, and runs your code again when the result is ready. Writing that "run this later" code well is the most important skill in Node.

Node's style has evolved over the years. Early APIs used callbacks: functions you pass in, called with an error or a result. Deeply nested callbacks became hard to read, so JavaScript added promises, objects that represent a future result. Today most code uses async and await, which let asynchronous code read from top to bottom like ordinary code while still being non-blocking.

In this lesson you'll recognise callbacks, use promises and async/await confidently, handle errors properly, run independent tasks in parallel with Promise.all, deal with partial failures, limit how many tasks run at once, add timeouts, and convert old callback APIs to promises.

Callbacks

Node's original APIs take a callback as their last argument, called with an error first, then the result:

JavaScript
import { readFile } from 'node:fs';

readFile('notes.txt', 'utf8', (err, text) => {
  if (err) return console.error('Could not read:', err.message);
  console.log(text);
});

When one step depends on another, callbacks nest inside callbacks, drifting further right with every step. This "callback hell" is why promises took over. You'll still see callbacks in older code and in event listeners.

Promises and async/await

A promise is either pending, fulfilled with a value, or rejected with an error. An async function always returns a promise, and await pauses that function until a promise settles:

JavaScript
import { readFile } from 'node:fs/promises';

async function loadConfig() {
  try {
    const text = await readFile('config.json', 'utf8');
    return JSON.parse(text);
  } catch (err) {
    console.error('Config problem:', err.message);
    return {};
  }
}
  • await only pauses the current async function; the rest of the program keeps running.
  • A rejected promise becomes a thrown error at the await, so ordinary try/catch works.
  • In ES modules you can use await at the top level of a file.
  • .then() and .catch() do the same job and are common in older code.

Running work in parallel

Awaiting one call after another runs them in sequence. When tasks don't depend on each other, start them together:

MethodResolves whenRejects when
Promise.all(ps)All succeed, giving an array of resultsAny one fails
Promise.allSettled(ps)All finish, giving { status, value/reason } for eachNever
Promise.race(ps)The first one settlesThe first one fails
Promise.any(ps)The first one succeedsAll fail

Starting thousands of tasks at once can overload a database or API, so process large lists in batches or with a small concurrency limit.

Timers, timeouts and promisify

  • import { setTimeout as sleep } from 'node:timers/promises' gives a promise-based delay: await sleep(500).
  • AbortSignal.timeout(ms) creates a signal that cancels an operation after a time limit; fetch and many Node APIs accept it.
  • util.promisify turns a callback-style function into one that returns a promise.
  • An unhandled rejected promise prints an error and, in modern Node, crashes the process, so always await promises or add .catch().

Node.js Basics Syntax

Async patterns

fs.readFile(path, 'utf8', (err, data) => { if (err) return; });
const data = await fs.promises.readFile(path, 'utf8');
async function load() { try { return await get(); } catch (e) { } }
const [a, b] = await Promise.all([getA(), getB()]);
const results = await Promise.allSettled(tasks);
const fastest = await Promise.race([primary(), backup()]);
import { setTimeout as sleep } from 'node:timers/promises';
await sleep(100);
await fetch(url, { signal: AbortSignal.timeout(3000) });
const readFileAsync = util.promisify(fs.readFile);

Node.js Basics Summary

  • Callbacks receive (err, result); nesting them leads to hard-to-read code.
  • Promises represent future results; async functions return promises and await waits for them.
  • Use try/catch around await to handle errors.
  • Promise.all runs independent tasks in parallel; allSettled reports every outcome.
  • Limit concurrency for large batches; use AbortSignal.timeout and promisify when needed.

Node.js Basics Examples

Each example shows the code, the output it produces and what each part does.

Example 1

Sequential versus parallel

Node.js
import { setTimeout as sleep } from 'node:timers/promises';

async function fetchPrice(item, ms) {
  await sleep(ms);
  return { item, price: item.length * 1.5 };
}

let start = performance.now();
const a = await fetchPrice('mug', 200);
const b = await fetchPrice('lamp', 200);
console.log(`Sequential: ${a.price + b.price} in ${Math.round((performance.now() - start) / 100) * 100} ms`);

start = performance.now();
const results = await Promise.all([fetchPrice('mug', 200), fetchPrice('lamp', 200)]);
const total = results.reduce((sum, r) => sum + r.price, 0);
console.log(`Parallel: ${total} in ${Math.round((performance.now() - start) / 100) * 100} ms`);
Output
Sequential: 10.5 in 400 ms
Parallel: 10.5 in 200 ms
Line-by-line explanation
  1. Line 4sleep stands in for a slow database or network call.
  2. Lines 9 to 10Each await waits before the next call starts, so the times add up.
  3. Line 11Times are rounded to the nearest 100 ms because they vary slightly.
  4. Line 14Promise.all starts both calls at once and waits for both.
Example 2

Handling partial failures

Node.js
async function checkService(name) {
  if (name === 'payments') throw new Error('timeout after 3s');
  return `${name} ok`;
}

const services = ['web', 'payments', 'search'];

try {
  await Promise.all(services.map(checkService));
} catch (err) {
  console.log(`Promise.all failed fast: ${err.message}`);
}

const outcomes = await Promise.allSettled(services.map(checkService));
outcomes.forEach((o, i) => {
  console.log(o.status === 'fulfilled' ? o.value : `${services[i]} DOWN (${o.reason.message})`);
});

const first = await Promise.any(services.map(checkService));
console.log(`First healthy: ${first}`);
Output
Promise.all failed fast: timeout after 3s
web ok
payments DOWN (timeout after 3s)
search ok
First healthy: web ok
Line-by-line explanation
  1. Line 2An async function that throws returns a rejected promise.
  2. Line 9One failure makes Promise.all reject, losing the other results.
  3. Line 14allSettled waits for every promise and reports each outcome.
  4. Line 16Each outcome has a status of fulfilled (with value) or rejected (with reason).
  5. Line 19Promise.any gives the first success and ignores failures.
Example 3

Batches, timeouts and promisify

Node.js
import { promisify } from 'node:util';
import { setTimeout as sleep } from 'node:timers/promises';

async function processInBatches(items, size, worker) {
  const results = [];
  for (let i = 0; i < items.length; i += size) {
    const batch = items.slice(i, i + size);
    console.log(`Batch: ${batch.join(', ')}`);
    results.push(...(await Promise.all(batch.map(worker))));
  }
  return results;
}

const doubled = await processInBatches([1, 2, 3, 4, 5], 2, async (n) => n * 2);
console.log(doubled);

try {
  await sleep(1000, null, { signal: AbortSignal.timeout(50) });
} catch (err) {
  console.log(`Gave up: ${err.name}`);
}

function oldStyle(name, callback) {
  setImmediate(() => callback(null, `Hello, ${name}`));
}
const modern = promisify(oldStyle);
console.log(await modern('Ada'));
Output
Batch: 1, 2
Batch: 3, 4
Batch: 5
[ 2, 4, 6, 8, 10 ]
Gave up: AbortError
Hello, Ada
Line-by-line explanation
  1. Lines 6 to 7The list is split into groups of size items.
  2. Line 9Each batch runs in parallel, but only one batch at a time.
  3. Line 18AbortSignal.timeout cancels the slow operation after 50 ms.
  4. Lines 23 to 25promisify wraps an error-first callback function so it returns a promise.

Common Node.js Basics Mistakes

A missing await

Don't
async function save(user) {
  db.insert(user);                // not awaited: errors are lost and save "finishes" too early
  console.log('Saved');
}
Do
async function save(user) {
  await db.insert(user);
  console.log('Saved');
}

await inside a loop for independent work

Don't
for (const id of ids) {
  results.push(await fetchUser(id));   // one at a time, slow
}
Do
const results = await Promise.all(ids.map((id) => fetchUser(id)));

Mixing callbacks and promises

Don't
const text = readFile('a.txt', 'utf8', (err, data) => data);   // text is undefined
Do
import { readFile } from 'node:fs/promises';
const text = await readFile('a.txt', 'utf8');

Node.js Basics in Real Projects

Async code in production

A typical API handler awaits a database query, then several independent calls in parallel with Promise.all, such as loading a user's profile, orders and recommendations at once. Health checks use Promise.allSettled to report which dependencies are down. Import jobs process records in batches to avoid overloading the database, and every outgoing HTTP request has a timeout so one slow service can't hang the whole app.

Node.js Basics FAQ

Can I use await inside forEach?

You can write it, but it won't do what you expect. forEach ignores the promise each callback returns, so the loop finishes immediately and the code after it runs before any of the work is done. Use for...of with await to run the steps one at a time, or await Promise.all(items.map(async (item) => ...)) to run them together.

Does async/await make my code run in parallel?

No. await pauses one function until a promise settles while the rest of the program carries on, but two awaits written one after the other still run in sequence. Work runs at the same time only when you start several operations before awaiting them, for example with Promise.all. Your JavaScript itself still runs on a single thread either way.

What happens to an unhandled promise rejection in Node.js?

Since Node 15, a rejected promise that nothing handles crashes the process with the error and its stack trace. That's deliberate: silently ignored failures are much harder to debug. Fix the cause by awaiting the promise inside try/catch or adding .catch(); a process.on('unhandledRejection') handler is only a last-resort place to log the problem.

What is the difference between process.nextTick, setImmediate and setTimeout?

process.nextTick callbacks run as soon as the current code finishes, even before promise callbacks. setImmediate runs on the next turn of the event loop, after waiting I/O has had a chance, and setTimeout(fn, 0) runs after at least a millisecond in the timers phase. In everyday code, prefer promises and setImmediate; heavy use of nextTick can starve the event loop.

How do I run async tasks one after another in a fixed order?

Use a for...of loop with await inside it: each iteration waits for the previous task to finish before starting the next. This is the right choice when each step depends on the one before, or when a service only accepts one request at a time. For independent tasks, running them together with Promise.all is much faster.

Node.js Basics Exercises

Answer each one and check it instantly. If you get stuck, open the hint before the answer.

Fill in the blankseasy

Complete the line so the function waits for the result.

const user =  findUser(42);
Multiple choiceeasy

What does an async function always return?

Predict the outputmedium

What does this print?

async function f() { return 5; }
console.log(f() instanceof Promise);
Multiple choicemedium

What does Promise.all do if one of its promises rejects?

Multiple choicehard

Why process a list of 10,000 API calls in batches rather than with one Promise.all?

Node.js Basics Quiz

5 questions to check what you learned.

  1. In an error-first callback, what is the first argument?

    • The result
    • The file name
    • An error, or null if there was none
    • A promise
  2. How do you handle an error from an awaited promise?

    • With .then only
    • With try/catch around the await
    • Errors can't happen
    • With process.exit
  3. Which method resolves with the first promise to succeed?

    • Promise.any
    • Promise.all
    • Promise.race
    • Promise.allSettled
  4. What does util.promisify do?

    • Makes code faster
    • Converts an error-first callback function into one that returns a promise
    • Creates a timer
    • Catches every error
  5. What happens in modern Node when a rejected promise is never handled?

    • Nothing
    • It's retried
    • An error is reported and the process exits
    • It's logged and ignored forever