Node.js
Async/Await
Callbacks
JavaScript
Asynchronous Programming

Node.JS Async / Await Dealing With Callbacks?

Master System Design with Codemia

Enhance your system design skills with over 120 practice problems, detailed solutions, and hands-on exercises.

Introduction

async and await make asynchronous JavaScript easier to read, but many Node.js APIs still use callbacks. The practical solution is to convert callback-based functions into promises, then compose them with await. This article shows reliable patterns for bridging both styles without hidden error handling bugs.

Understand Error-First Callback Shape

Classic Node callbacks usually follow the error, result convention.

javascript
1const fs = require("fs");
2
3fs.readFile("notes.txt", "utf8", (err, data) => {
4  if (err) {
5    console.error("read failed", err);
6    return;
7  }
8  console.log(data);
9});

To use await, wrap this behavior in a promise.

Convert Callbacks With util.promisify

Node provides util.promisify for standard callback signatures.

javascript
1const fs = require("fs");
2const util = require("util");
3
4const readFileAsync = util.promisify(fs.readFile);
5
6async function loadNotes() {
7  try {
8    const data = await readFileAsync("notes.txt", "utf8");
9    console.log(data);
10  } catch (err) {
11    console.error("loadNotes failed", err);
12  }
13}
14
15loadNotes();

This is usually the quickest migration path for built-in modules.

Manual Promise Wrapping for Custom APIs

If the callback API is non-standard, wrap it manually.

javascript
1function legacyFetchUser(id, callback) {
2  setTimeout(() => {
3    if (id <= 0) {
4      callback(new Error("invalid id"));
5      return;
6    }
7    callback(null, { id, name: "Ari" });
8  }, 50);
9}
10
11function fetchUserAsync(id) {
12  return new Promise((resolve, reject) => {
13    legacyFetchUser(id, (err, user) => {
14      if (err) {
15        reject(err);
16        return;
17      }
18      resolve(user);
19    });
20  });
21}
22
23async function demo() {
24  const user = await fetchUserAsync(1);
25  console.log(user);
26}
27
28demo().catch(console.error);

Manual wrapping gives full control over argument mapping and validation.

Compose Sequential and Parallel Workflows

Once functions return promises, composition is straightforward.

javascript
1async function processUsers(ids) {
2  // Sequential
3  for (const id of ids) {
4    const user = await fetchUserAsync(id);
5    console.log("sequential", user.name);
6  }
7
8  // Parallel
9  const all = await Promise.all(ids.map(fetchUserAsync));
10  console.log("parallel count", all.length);
11}
12
13processUsers([1, 2, 3]).catch(console.error);

Use sequential mode when calls depend on previous results. Use parallel mode for independent calls where throughput matters.

Avoid Callback and Promise Mixing in One API

Pick one async contract at module boundaries. If a function both returns a promise and expects a callback, call sites become ambiguous.

A clean migration strategy is:

  • Wrap legacy callbacks in adapter functions.
  • Export promise-first APIs from new modules.
  • Keep callback style only at integration edges until migrated.

This keeps error handling predictable and avoids duplicate completion bugs.

Add Timeouts and Cancellation

Real applications need cancellation boundaries so hanging calls do not block request pipelines forever. For promise-based APIs, combine wrappers with timeout logic.

javascript
1function withTimeout(promise, ms) {
2  return Promise.race([
3    promise,
4    new Promise((_, reject) =>
5      setTimeout(() => reject(new Error("timeout")), ms)
6    ),
7  ]);
8}
9
10async function safeFetchUser(id) {
11  return withTimeout(fetchUserAsync(id), 500);
12}
13
14safeFetchUser(1)
15  .then((user) => console.log(user))
16  .catch((err) => console.error(err.message));

When callback APIs support cancellation handles, expose them in your adapter design so higher layers can abort work intentionally.

Testing Promise Adapters

Write focused tests for the adapter layer to ensure callback errors map to rejected promises correctly.

javascript
1const assert = require("assert");
2
3async function testRejectsOnInvalidId() {
4  try {
5    await fetchUserAsync(0);
6    assert.fail("expected rejection");
7  } catch (err) {
8    assert.equal(err.message, "invalid id");
9  }
10}
11
12testRejectsOnInvalidId();

Small adapter tests prevent subtle async regressions during refactors.

Common Pitfalls

A common bug is forgetting to return after reject in manual wrappers, which can lead to multiple resolution attempts. Another issue is using await inside Array.prototype.forEach, which does not wait as many developers expect. Prefer for...of or Promise.all with map. Teams also sometimes swallow errors in inner callbacks and then wonder why outer try blocks do not catch them. Ensure callback errors are converted to promise rejections. Finally, avoid promisifying the same function repeatedly inside hot paths. Create adapters once and reuse them.

Summary

  • Bridge callback APIs to promises before using async and await.
  • Use util.promisify for standard error-first callbacks.
  • Manually wrap non-standard callback signatures.
  • Choose sequential or parallel composition based on dependency and throughput.
  • Keep module APIs consistent to avoid mixed async contracts.

Course illustration
Course illustration

All Rights Reserved.