Node.js
async/await
error handling
debugging
JavaScript

How to trace async/await errors in node.js?

Interview Questions practice on Codemia

Over 8,000 real interview questions from top companies, searchable by company and role.

Browse interview questions

Introduction

Async and await make Node.js code easier to read, but they do not make failures easier to diagnose by themselves. Errors still cross promise boundaries, lose request context, or disappear behind generic logs unless you add structure around them.

Core Sections

Start with explicit try and catch

The first rule is simple: catch errors where you can add useful context, not where you can hide them. A stack trace that says only ECONNRESET is rarely enough. A stack trace that also says which user, endpoint, or background job failed is much more actionable.

javascript
1async function loadInvoice(apiClient, invoiceId) {
2  try {
3    return await apiClient.fetchInvoice(invoiceId);
4  } catch (error) {
5    error.message = `loadInvoice failed for invoice ${invoiceId}: ${error.message}`;
6    throw error;
7  }
8}

This pattern preserves the original failure while adding domain context. If you need stronger error chaining in modern Node.js, wrap with a new Error and a cause.

javascript
1async function sendReceipt(mailer, receipt) {
2  try {
3    await mailer.send(receipt);
4  } catch (error) {
5    throw new Error("sendReceipt failed", { cause: error });
6  }
7}

Avoid losing failures by forgetting await

A common debugging trap is assuming a try and catch block protects a promise when the promise was never awaited. In that case, the rejection happens later and escapes the local handler.

javascript
1async function brokenExample(service) {
2  try {
3    service.runTask();
4    console.log("task started");
5  } catch (error) {
6    console.error("This will not catch the rejection", error);
7  }
8}
9
10async function fixedExample(service) {
11  try {
12    await service.runTask();
13    console.log("task finished");
14  } catch (error) {
15    console.error("Caught correctly", error);
16  }
17}

If an error seems to bypass your local handler, check every promise-returning call inside the function. Missing one await is enough to make the trace misleading.

Add process-level diagnostics, but do not treat them as recovery

Node gives you hooks for unhandled promise rejections and uncaught exceptions. These are valuable for logging the last visible state before process exit. They are not a substitute for application-level error handling.

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

Use these handlers to record diagnostic information, then restart the process cleanly through your supervisor. Continuing after an uncaught exception can leave the process in an inconsistent state.

For local debugging, start Node with flags that improve traces.

bash
node --enable-source-maps --unhandled-rejections=strict server.js

--enable-source-maps helps when TypeScript or bundled code is involved. Strict unhandled rejections force failures to surface instead of being ignored.

Correlate async work with request-scoped context

In services, the hardest part is often connecting one error line to the request that caused it. AsyncLocalStorage lets you carry a request id across awaited boundaries so logs stay correlated.

javascript
1import { AsyncLocalStorage } from "node:async_hooks";
2import crypto from "node:crypto";
3
4const requestContext = new AsyncLocalStorage();
5
6function withRequestContext(handler) {
7  return (req, res, next) => {
8    const requestId = req.headers["x-request-id"] || crypto.randomUUID();
9    requestContext.run({ requestId }, () => handler(req, res, next));
10  };
11}
12
13function log(message, extra = {}) {
14  const store = requestContext.getStore();
15  console.log({
16    requestId: store?.requestId,
17    message,
18    ...extra,
19  });
20}

Once this exists, every catch block can include the request id automatically. That reduces time spent guessing which upstream call triggered the problem.

Use an async wrapper in web handlers

HTTP frameworks often benefit from a small helper that forwards rejected promises into centralized error middleware. This removes repetitive try and catch blocks from every route.

javascript
1function asyncHandler(fn) {
2  return function wrappedHandler(req, res, next) {
3    Promise.resolve(fn(req, res, next)).catch(next);
4  };
5}
6
7app.get(
8  "/users/:id",
9  asyncHandler(async (req, res) => {
10    const user = await userService.getById(req.params.id);
11    res.json(user);
12  })
13);
14
15app.use((error, req, res, next) => {
16  log("request failed", { path: req.path, error: error.stack });
17  res.status(500).json({ error: "internal_error" });
18});

This pattern is practical because it centralizes response formatting and logging, while each handler still keeps explicit await calls.

Reproduce failures with tests

Tracing gets easier when you can force the failure in isolation. Promise rejection tests protect against regressions where errors stop propagating.

javascript
1import assert from "node:assert/strict";
2
3async function parsePayload(payload) {
4  if (!payload.id) {
5    throw new Error("payload.id is required");
6  }
7  return payload.id;
8}
9
10await assert.rejects(
11  () => parsePayload({}),
12  /payload.id is required/
13);

A failing unit test is usually faster to debug than a production log stream.

Common Pitfalls

  • Forgetting await inside a try and catch block, which lets the rejection escape and makes the local handler look broken.
  • Logging only error.message and dropping error.stack, request ids, or input context that would make the trace actionable.
  • Catching errors and returning fallback values everywhere, which hides real faults and allows corrupted state to move deeper into the system.
  • Treating unhandledRejection as a place to continue normal execution instead of as a last diagnostic hook before controlled shutdown.
  • Mixing callback-style APIs and promise-based code without converting them consistently, which creates fragmented stack traces and inconsistent error paths.

Summary

  • Catch async errors where you can add business context, then rethrow them.
  • Verify every promise-returning call is actually awaited.
  • Use process-level hooks for diagnostics, not silent recovery.
  • Attach request-scoped metadata with AsyncLocalStorage so logs remain traceable across awaits.
  • Add focused rejection tests so async failures are reproducible outside production.

Related reading
Free course
Beginner
7 lessons
2 hours
Tackling System Design Interview Problems

A short course that equips you with the skills to approach system design interviews methodically.

Start the free course
Track what you have practised

A free account saves your progress, solutions and study plan across every problem on Codemia.

Interview Questions practice on Codemia

Over 8,000 real interview questions from top companies, searchable by company and role.

Browse interview questions

All Rights Reserved.