async
error-handling
nodejs
asynchronous-programming
javascript

Best way to handle the error in async node

Master System Design with Codemia

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

Introduction

Error handling in asynchronous Node.js code should be explicit, consistent, and layered. Without a strategy, rejected promises and callback errors can leak into unhandled exceptions and crash processes unexpectedly. The best practice is to standardize on async and await where possible, centralize request-level handling, and keep process-level handlers as a last safety net.

Core Pattern with async and await

Use try and catch around awaited operations and throw domain-specific errors when needed.

javascript
1async function getUserById(db, id) {
2  try {
3    const user = await db.users.findById(id);
4    if (!user) {
5      const err = new Error("User not found");
6      err.code = "USER_NOT_FOUND";
7      throw err;
8    }
9    return user;
10  } catch (err) {
11    err.context = { id };
12    throw err;
13  }
14}

Adding context before rethrowing helps logs and debugging.

Express Route Error Wrapper

In Express, wrap async route handlers so rejections reach centralized middleware.

javascript
1import express from "express";
2
3const app = express();
4
5const asyncHandler = fn => (req, res, next) => {
6  Promise.resolve(fn(req, res, next)).catch(next);
7};
8
9app.get("/users/:id", asyncHandler(async (req, res) => {
10  const user = await getUserById(req.app.locals.db, req.params.id);
11  res.json(user);
12}));
13
14app.use((err, req, res, next) => {
15  console.error(err);
16  const status = err.code === "USER_NOT_FOUND" ? 404 : 500;
17  res.status(status).json({ message: err.message });
18});

This avoids repetitive try and catch blocks in every route.

Promise Chains and Error Propagation

Legacy code may still use .then and .catch. Keep chains flat and return promises properly.

javascript
1fetchRemoteConfig()
2  .then(cfg => connectService(cfg))
3  .then(client => client.ping())
4  .catch(err => {
5    console.error("startup failed", err);
6    process.exitCode = 1;
7  });

Missing return in intermediate .then callbacks is a common source of swallowed errors.

Callback-Based APIs

Some Node APIs still use error-first callbacks. Normalize them with util.promisify.

javascript
1import { promisify } from "node:util";
2import fs from "node:fs";
3
4const readFileAsync = promisify(fs.readFile);
5
6async function loadConfig() {
7  try {
8    const data = await readFileAsync("config.json", "utf8");
9    return JSON.parse(data);
10  } catch (err) {
11    err.message = `Failed to load config: ${err.message}`;
12    throw err;
13  }
14}

Standardizing async style simplifies error paths.

Operational Versus Programmer Errors

Treat errors differently by category:

  • Operational errors: network timeouts, missing records, dependency failures.
  • Programmer errors: null dereference, type bugs, invariant violations.

Operational errors should be handled and returned gracefully. Programmer errors often justify process restart after logging because application state may be corrupted.

Process-Level Safety Handlers

Keep global handlers as fallback, not primary logic.

javascript
1process.on("unhandledRejection", reason => {
2  console.error("unhandledRejection", reason);
3});
4
5process.on("uncaughtException", err => {
6  console.error("uncaughtException", err);
7  process.exit(1);
8});

These handlers capture failures, but route-level and service-level handling should still be your first line of defense.

Structured Logging and Correlation IDs

Error handling is only useful if diagnostics are searchable. Include request id or job id with logs.

javascript
1function logError(err, context = {}) {
2  console.error(JSON.stringify({
3    level: "error",
4    message: err.message,
5    stack: err.stack,
6    context
7  }));
8}

Structured logs make incident response faster in distributed systems.

Retry and Timeout Boundaries

Not every async error should retry. Define explicit retry policy and timeout limits.

javascript
1import fetch from "node-fetch";
2
3async function fetchWithTimeout(url, timeoutMs) {
4  const controller = new AbortController();
5  const timer = setTimeout(() => controller.abort(), timeoutMs);
6  try {
7    const res = await fetch(url, { signal: controller.signal });
8    if (!res.ok) throw new Error(`HTTP ${res.status}`);
9    return await res.json();
10  } finally {
11    clearTimeout(timer);
12  }
13}

Bounded retries with backoff improve reliability without creating retry storms.

Common Pitfalls

  • Mixing callback, promise, and async styles inconsistently. Fix by standardizing on async and await where possible.
  • Handling errors only at process level. Fix by catching errors at request and service boundaries first.
  • Swallowing promise rejections without logging context. Fix by logging structured details and rethrowing intentionally.
  • Returning generic 500 for all cases. Fix by mapping domain errors to appropriate status codes.
  • Retrying every failure blindly. Fix by applying targeted retry logic with limits and timeouts.

Summary

  • Use layered error handling: function, route, then process fallback.
  • Prefer async and await with explicit try and catch blocks.
  • Centralize HTTP framework error middleware for consistency.
  • Distinguish operational errors from programmer errors.
  • Add structured logging and bounded retries for production resilience.

Course illustration
Course illustration

All Rights Reserved.