Home JavaScript Node.js Error Handling: async/await, Promises & Real Patterns

Node.js Error Handling: async/await, Promises & Real Patterns

In Plain English 🔥
Imagine you're a chef running a restaurant kitchen. When an order comes in for a dish you're out of ingredients for, you don't just silently throw the ticket in the bin — you tell the waiter, who tells the customer, who can then order something else. Node.js error handling is exactly that chain of communication. Without it, your app quietly fails while your users stare at a spinning loader forever. Good error handling is how your app says 'something went wrong, and here's what' instead of just dying in silence.
⚡ Quick Answer
Imagine you're a chef running a restaurant kitchen. When an order comes in for a dish you're out of ingredients for, you don't just silently throw the ticket in the bin — you tell the waiter, who tells the customer, who can then order something else. Node.js error handling is exactly that chain of communication. Without it, your app quietly fails while your users stare at a spinning loader forever. Good error handling is how your app says 'something went wrong, and here's what' instead of just dying in silence.

Node.js powers millions of production servers, APIs, and real-time applications — and the difference between an app that recovers gracefully from failure and one that crashes at 2am taking your database with it comes down almost entirely to error handling. It's not a nice-to-have. It's the foundation of reliable software. Yet it's consistently the thing junior developers either skip or do wrong, leading to unhandled promise rejections, cryptic crashes, and bugs that only appear under production load.

The core problem Node.js introduces — one that doesn't exist in simple synchronous code — is that errors can now come from multiple timelines simultaneously. A file read, a database query, an HTTP call, and a timer can all fail at different moments, and if you're not deliberately catching each failure path, Node.js will eventually throw an uncaught exception and terminate your process. Worse, with Promises, errors can fail completely silently unless you've wired up rejection handlers. This is the bug that kills production apps.

By the end of this article you'll understand the four error delivery mechanisms in Node.js (throw, callbacks, Promise rejections, and EventEmitter errors), how to build a layered error handling strategy using custom error classes, when to use try/catch versus .catch(), how to handle process-level uncaught exceptions safely, and how to structure middleware-based error handling in an Express API. These are production-grade patterns used in real codebases — not toy examples.

The Four Ways Node.js Delivers Errors — and Why You Need to Know All Four

In a browser, errors mostly come from one place: synchronous throws and maybe a fetch() rejection. Node.js is different. Because it's designed for I/O-heavy work — reading files, querying databases, making HTTP requests — errors arrive through four completely separate channels, and missing any one of them means silent failures.

The first is synchronous throws — these work exactly like you'd expect, and a try/catch block handles them fine. The second is the error-first callback pattern, the original Node.js convention where the first argument to a callback is always an error object or null. The third is Promise rejections, which arrived with modern async APIs and require either .catch() chaining or try/catch inside async functions. The fourth is EventEmitter error events, used by streams, HTTP servers, and database connections — if you don't attach an 'error' event listener, Node.js throws an uncaught exception by default.

Understanding which channel a library uses is the first question you should ask when integrating anything new. fs.readFile uses callbacks. fetch() and most modern ORMs use Promises. An HTTP server uses EventEmitter. Get this wrong and you'll have error handling code that looks correct but catches nothing.

errorChannels.js · JAVASCRIPT
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162
const fs = require('fs');
const { EventEmitter } = require('events');

// ─────────────────────────────────────────
// CHANNEL 1: Synchronous throw
// ─────────────────────────────────────────
function parsUserInput(rawJson) {
  // JSON.parse throws synchronously on invalid input
  return JSON.parse(rawJson);
}

try {
  const result = parsUserInput('{ invalid json }');
} catch (syncError) {
  // syncError is a SyntaxError instance — we can inspect it
  console.log('[Sync] Caught:', syncError.message);
}

// ─────────────────────────────────────────
// CHANNEL 2: Error-first callback (classic Node.js pattern)
// ─────────────────────────────────────────
fs.readFile('/tmp/does-not-exist.txt', 'utf8', (callbackError, fileContents) => {
  if (callbackError) {
    // Always check the FIRST argument — null means success, Error means failure
    console.log('[Callback] Caught:', callbackError.code); // e.g. ENOENT
    return; // CRITICAL: return here or code below runs with undefined fileContents
  }
  console.log('File contents:', fileContents);
});

// ─────────────────────────────────────────
// CHANNEL 3: Promise rejection
// ─────────────────────────────────────────
async function fetchUserData(userId) {
  // Simulating a database call that rejects
  return Promise.reject(new Error(`User ${userId} not found in database`));
}

async function loadUserProfile() {
  try {
    // Without this try/catch, the rejection becomes an UnhandledPromiseRejection
    const user = await fetchUserData(42);
    console.log('User:', user);
  } catch (promiseError) {
    console.log('[Promise] Caught:', promiseError.message);
  }
}

loadUserProfile();

// ─────────────────────────────────────────
// CHANNEL 4: EventEmitter 'error' event
// ─────────────────────────────────────────
const dataStream = new EventEmitter();

// If you remove this listener, the emit below CRASHES the process
dataStream.on('error', (emitterError) => {
  console.log('[EventEmitter] Caught:', emitterError.message);
});

// Simulate a stream error (e.g. a broken pipe or failed socket)
dataStream.emit('error', new Error('Connection to upstream service dropped'));
▶ Output
[Sync] Caught: Unexpected token i in JSON at position 2
[Promise] Caught: User 42 not found in database
[EventEmitter] Caught: Connection to upstream service dropped
[Callback] Caught: ENOENT
⚠️
Watch Out: The Missing return in CallbacksIn error-first callbacks, forgetting to `return` after handling the error is one of the most common Node.js bugs. Without it, execution continues into your success path with undefined or corrupt data. Always write `if (err) { handle(err); return; }` — not just `if (err) { handle(err); }`.

Custom Error Classes — Stop Throwing Plain Strings and Start Throwing Meaning

When you throw new Error('something failed') everywhere, your error handlers have almost nothing to work with. Is this a validation error the user caused? A database timeout you should retry? A third-party API going down? You can't tell — and neither can your monitoring tools.

The solution is custom error classes that extend the built-in Error. This gives every error a type you can check with instanceof, a machine-readable code property you can switch on, an HTTP status code if you're building an API, and any additional context (like the offending user ID or database query) that makes debugging faster. This is how Express, Mongoose, and virtually every mature Node.js library handles errors internally.

The key insight is that errors are first-class data. They carry information about what went wrong, who caused it, and how to recover. A generic Error discards all of that. A custom error class preserves it all the way from the database layer up to the HTTP response, so your global error handler can send a 422 for validation failures, a 503 for service timeouts, and a 500 for genuine bugs — without needing a massive if-else chain to guess what happened.

customErrors.js · JAVASCRIPT
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293
// ─────────────────────────────────────────
// Base application error — all custom errors extend this
// ─────────────────────────────────────────
class AppError extends Error {
  constructor(message, statusCode, errorCode) {
    super(message); // Pass message to the built-in Error constructor
    this.name = this.constructor.name; // 'ValidationError', 'DatabaseError', etc.
    this.statusCode = statusCode;      // HTTP status to return to the client
    this.errorCode = errorCode;        // Machine-readable string for programmatic checks
    this.isOperational = true;         // Marks this as a KNOWN error, not a bug

    // Preserves the correct stack trace in V8 (Node.js's JS engine)
    Error.captureStackTrace(this, this.constructor);
  }
}

// ─────────────────────────────────────────
// Specific error types — each carries its own domain context
// ─────────────────────────────────────────
class ValidationError extends AppError {
  constructor(message, fieldName) {
    super(message, 422, 'VALIDATION_ERROR');
    this.fieldName = fieldName; // Which form field caused the problem
  }
}

class DatabaseError extends AppError {
  constructor(message, queryDetails) {
    super(message, 503, 'DATABASE_ERROR');
    this.queryDetails = queryDetails; // Store the failing query for logging
    this.isRetryable = true;          // Hint to the caller: safe to retry
  }
}

class NotFoundError extends AppError {
  constructor(resourceType, resourceId) {
    super(`${resourceType} with ID ${resourceId} was not found`, 404, 'NOT_FOUND');
    this.resourceType = resourceType;
    this.resourceId = resourceId;
  }
}

// ─────────────────────────────────────────
// Simulated service layer that throws typed errors
// ─────────────────────────────────────────
async function createUserAccount(userData) {
  // Validate required fields before touching the database
  if (!userData.email || !userData.email.includes('@')) {
    throw new ValidationError('Email address is not valid', 'email');
  }

  if (!userData.password || userData.password.length < 8) {
    throw new ValidationError('Password must be at least 8 characters', 'password');
  }

  // Simulate a database connection failure
  const dbAvailable = false;
  if (!dbAvailable) {
    throw new DatabaseError(
      'Primary database is unreachable',
      { operation: 'INSERT', table: 'users' }
    );
  }
}

// ─────────────────────────────────────────
// Centralized error handler — uses instanceof to route by type
// ─────────────────────────────────────────
async function handleCreateUser(requestBody) {
  try {
    await createUserAccount(requestBody);
    console.log('User created successfully');
  } catch (error) {
    if (error instanceof ValidationError) {
      // Client made a bad request — tell them exactly what's wrong
      console.log(`[${error.statusCode}] Validation failed on field '${error.fieldName}': ${error.message}`);
    } else if (error instanceof DatabaseError) {
      // Infrastructure problem — log details, maybe retry
      console.log(`[${error.statusCode}] Database error: ${error.message}`);
      console.log('  Query context:', JSON.stringify(error.queryDetails));
      console.log('  Retryable:', error.isRetryable);
    } else {
      // Unknown bug — escalate and don't expose internals to the client
      console.log('[500] Unexpected error — this is a bug:', error.message);
    }
  }
}

// Test with bad email
await handleCreateUser({ email: 'not-an-email', password: 'securepassword123' });

// Test with db down
await handleCreateUser({ email: 'user@example.com', password: 'securepassword123' });
▶ Output
[422] Validation failed on field 'email': Email address is not valid
[503] Database error: Primary database is unreachable
Query context: {"operation":"INSERT","table":"users"}
Retryable: true
⚠️
Pro Tip: The isOperational FlagThe `isOperational: true` flag on AppError is a pattern from the Node.js community (popularised by Sindre Sorhus and Joyee Cheung). It lets your process-level crash handler distinguish between errors you anticipated (validation failures, network timeouts) and actual bugs. Only crash and restart on non-operational errors. For operational ones, log them, respond to the client, and keep the process running.

Express Global Error Middleware — One Handler to Rule Them All

In a real Express API, you could put try/catch blocks in every single route handler. But that means duplicating your error response logic in dozens of places. When you need to change how errors are formatted — say, switching to a JSON:API error format — you're editing every single file. That's not maintainable.

Express has a built-in mechanism for centralised error handling: a middleware function with four arguments (err, req, res, next). Express detects the four-argument signature and treats it as an error handler, only calling it when next(error) is invoked. This is the correct pattern. Route handlers try/catch their async logic and call next(error), and one global handler at the bottom of your middleware stack decides how to respond.

The tricky part that trips everyone up: Express doesn't automatically catch errors thrown inside async route handlers. If an async function throws and you haven't called next(error), Express never sees it. You need a small wrapper utility — often called asyncHandler or catchAsync — that wraps every async route and automatically forwards any rejection to next. This is a one-time fix that makes every route in your app safely handled.

expressErrorHandling.js · JAVASCRIPT
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130
const express = require('express');

const app = express();
app.use(express.json());

// ─────────────────────────────────────────
// Re-use custom error classes from previous section
// ─────────────────────────────────────────
class AppError extends Error {
  constructor(message, statusCode, errorCode) {
    super(message);
    this.name = this.constructor.name;
    this.statusCode = statusCode;
    this.errorCode = errorCode;
    this.isOperational = true;
    Error.captureStackTrace(this, this.constructor);
  }
}

class NotFoundError extends AppError {
  constructor(resourceType, resourceId) {
    super(`${resourceType} with id '${resourceId}' does not exist`, 404, 'NOT_FOUND');
  }
}

// ─────────────────────────────────────────
// asyncHandler: wraps async routes so rejections reach next()
// Without this, unhandled promise rejections crash Express silently
// ─────────────────────────────────────────
const asyncHandler = (routeFunction) => {
  return (req, res, next) => {
    // If routeFunction returns a rejected promise, .catch(next) calls next(error)
    Promise.resolve(routeFunction(req, res, next)).catch(next);
  };
};

// ─────────────────────────────────────────
// Simulated database call — returns a Promise
// ─────────────────────────────────────────
async function findUserById(userId) {
  const mockDatabase = {
    '101': { id: 101, name: 'Alice Nakamura', role: 'admin' },
    '202': { id: 202, name: 'Ben Okafor', role: 'viewer' },
  };
  const user = mockDatabase[String(userId)];
  if (!user) {
    // Throw a typed error — the global handler will format the response
    throw new NotFoundError('User', userId);
  }
  return user;
}

// ─────────────────────────────────────────
// Route handlers — clean, no error formatting logic here
// ─────────────────────────────────────────
app.get(
  '/users/:userId',
  asyncHandler(async (req, res) => {
    // If findUserById throws, asyncHandler catches it and calls next(error)
    const user = await findUserById(req.params.userId);
    res.status(200).json({ success: true, data: user });
  })
);

app.get(
  '/users/:userId/permissions',
  asyncHandler(async (req, res) => {
    const user = await findUserById(req.params.userId);
    // Simulate an unexpected runtime error (a genuine bug)
    if (user.role === 'admin') {
      throw new Error('Permissions module failed to load — internal bug');
    }
    res.status(200).json({ success: true, permissions: ['read'] });
  })
);

// ─────────────────────────────────────────
// Global error handling middleware — MUST be registered LAST
// Express recognises the 4-argument signature (err, req, res, next)
// ─────────────────────────────────────────
app.use((err, req, res, next) => { // eslint-disable-line no-unused-vars
  // Log everything — even operational errors need an audit trail
  console.error(`[${new Date().toISOString()}] ${err.name}: ${err.message}`);

  // Operational errors: we know what this is, safe to tell the client
  if (err.isOperational) {
    return res.status(err.statusCode).json({
      success: false,
      error: {
        code: err.errorCode,
        message: err.message,
      },
    });
  }

  // Non-operational (unexpected bugs): hide internals, alert your team
  console.error('CRITICAL — non-operational error, needs investigation:', err.stack);
  return res.status(500).json({
    success: false,
    error: {
      code: 'INTERNAL_ERROR',
      message: 'An unexpected error occurred. Our team has been notified.',
    },
  });
});

// ─────────────────────────────────────────
// Process-level safety net — last resort for anything that slips through
// ─────────────────────────────────────────
process.on('unhandledRejection', (reason, promise) => {
  // Log it but DON'T crash — an http server should stay up if possible
  console.error('Unhandled Promise Rejection at:', promise, 'reason:', reason);
  // In a real app: alert via PagerDuty/Sentry here
});

process.on('uncaughtException', (error) => {
  // This means something bypassed ALL error handling — the process is unstable
  console.error('Uncaught Exception — process will restart:', error.stack);
  // Give in-flight requests a moment to finish, then restart cleanly via PM2/Docker
  process.exit(1);
});

app.listen(3000, () => console.log('Server running on port 3000'));

// ─────────────────────────────────────────
// Example responses (simulate with curl or a test):
// GET /users/101   → 200 { success: true, data: { id: 101, name: 'Alice Nakamura' } }
// GET /users/999   → 404 { success: false, error: { code: 'NOT_FOUND', message: "User with id '999' does not exist" } }
// GET /users/101/permissions → 500 { success: false, error: { code: 'INTERNAL_ERROR', message: '...' } }
// ─────────────────────────────────────────
▶ Output
Server running on port 3000
[2024-01-15T10:23:01.442Z] NotFoundError: User with id '999' does not exist
[2024-01-15T10:23:04.118Z] Error: Permissions module failed to load — internal bug
CRITICAL — non-operational error, needs investigation: Error: Permissions module failed to load
at /app/expressErrorHandling.js:72:13
at ...
🔥
Interview Gold: Why Four Arguments?Express specifically looks for a middleware function with exactly four parameters to identify it as an error handler. If you define `(err, req, res, next)` but accidentally write it as `(req, res, next)`, Express treats it as a regular middleware and your errors will never reach it. This is a real bug that's hard to spot — always keep the four-argument signature, even if you don't use `next`.

Async Error Patterns in Practice — Avoiding the Swallowed Error Trap

The most insidious Node.js bugs are the ones where an error happens, nothing crashes, but nothing works either. This is the 'swallowed error' — a rejection that gets silently ignored because it happened inside a Promise chain without a .catch(), or inside an async function that was called without await.

There are three specific patterns that cause this in real codebases. First, calling an async function without await and without catching the returned Promise — the function runs, fails, and nobody knows. Second, using Promise.all without realising that one rejection cancels everything but still needs a .catch(). Third, attaching .catch() to the wrong part of a Promise chain so it catches the setup but not the async operation.

The fix isn't complicated — it's just disciplined. Every async function call either gets awaited inside a try/catch, or gets a .catch() chained directly on it. No exceptions. Tools like ESLint's @typescript-eslint/no-floating-promises rule can enforce this automatically in CI, which is how serious teams catch these bugs before they ship.

asyncErrorPatterns.js · JAVASCRIPT
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108
// ─────────────────────────────────────────
// Simulated async operations with realistic error scenarios
// ─────────────────────────────────────────
async function fetchOrderFromDatabase(orderId) {
  if (orderId <= 0) throw new Error(`Invalid order ID: ${orderId}`);
  return { id: orderId, product: 'Wireless Keyboard', quantity: 2, price: 79.99 };
}

async function fetchInventoryLevel(productName) {
  if (productName === 'Out of Stock Item') {
    throw new Error(`No inventory record found for: ${productName}`);
  }
  return { product: productName, available: 150 };
}

async function sendConfirmationEmail(emailAddress) {
  if (!emailAddress.includes('@')) {
    throw new Error(`Invalid email address: ${emailAddress}`);
  }
  return { sent: true, to: emailAddress };
}

// ─────────────────────────────────────────
// PATTERN 1: Sequential operations — one failure stops the chain
// Use when each step DEPENDS on the previous result
// ─────────────────────────────────────────
async function processOrderSequentially(orderId, customerEmail) {
  console.log('\n--- Sequential Processing ---');
  try {
    // Each await pauses here — if any throw, we jump to catch immediately
    const order = await fetchOrderFromDatabase(orderId);
    console.log(`Order fetched: ${order.product}`);

    const inventory = await fetchInventoryLevel(order.product);
    console.log(`Inventory confirmed: ${inventory.available} units available`);

    const emailResult = await sendConfirmationEmail(customerEmail);
    console.log(`Confirmation sent to: ${emailResult.to}`);

  } catch (processingError) {
    // One catch handles ALL three operations — clean and maintainable
    console.error(`Order processing failed: ${processingError.message}`);
  }
}

// ─────────────────────────────────────────
// PATTERN 2: Parallel operations — run independent tasks concurrently
// Use when steps DON'T depend on each other (faster than sequential)
// ─────────────────────────────────────────
async function loadDashboardData(userId) {
  console.log('\n--- Parallel Processing with Promise.allSettled ---');

  // Promise.all fails fast if ANY promise rejects — good for all-or-nothing scenarios
  // Promise.allSettled waits for ALL and reports each result — good for dashboards
  const [ordersResult, inventoryResult] = await Promise.allSettled([
    fetchOrderFromDatabase(userId),
    fetchInventoryLevel('Out of Stock Item'), // This will fail
  ]);

  // allSettled gives us { status: 'fulfilled' | 'rejected', value/reason }
  if (ordersResult.status === 'fulfilled') {
    console.log('Orders loaded:', ordersResult.value.product);
  } else {
    console.error('Orders failed to load:', ordersResult.reason.message);
  }

  if (inventoryResult.status === 'fulfilled') {
    console.log('Inventory loaded:', inventoryResult.value.available);
  } else {
    // Dashboard still renders — just without this section
    console.warn('Inventory unavailable, showing cached data:', inventoryResult.reason.message);
  }
}

// ─────────────────────────────────────────
// PATTERN 3: The floating promise trap — the silent killer
// ─────────────────────────────────────────
function demonstrateFloatingPromiseDanger() {
  console.log('\n--- Floating Promise (DANGER) ---');

  // BAD: This async call is NOT awaited and has NO .catch()
  // The error disappears. In older Node versions this was fully silent.
  // In Node 15+ it becomes an UnhandledPromiseRejection and CRASHES the process.
  // fetchOrderFromDatabase(-1); // <- DO NOT DO THIS

  // GOOD: Always attach a .catch() if you can't await
  fetchOrderFromDatabase(-1).catch((floatingError) => {
    console.error('Caught floating promise error:', floatingError.message);
  });

  // ALSO GOOD: Fire-and-forget with an async IIFE if you need async context
  (async () => {
    try {
      await sendConfirmationEmail('not-a-valid-email');
    } catch (iifError) {
      console.error('Caught IIFE async error:', iifError.message);
    }
  })();
}

// ─────────────────────────────────────────
// Run all patterns
// ─────────────────────────────────────────
(async () => {
  await processOrderSequentially(5, 'customer@shop.com');
  await loadDashboardData(5);
  demonstrateFloatingPromiseDanger();
})();
▶ Output
--- Sequential Processing ---
Order fetched: Wireless Keyboard
Inventory confirmed: 150 units available
Confirmation sent to: customer@shop.com

--- Parallel Processing with Promise.allSettled ---
Orders loaded: Wireless Keyboard
Inventory unavailable, showing cached data: No inventory record found for: Out of Stock Item

--- Floating Promise (DANGER) ---
Caught floating promise error: Invalid order ID: -1
Caught IIFE async error: Invalid email address: not-a-valid-email
⚠️
Pro Tip: Promise.allSettled vs Promise.allUse `Promise.all` when all results are required to proceed — like collecting data before rendering a page that needs everything. Use `Promise.allSettled` when each result is independent — like a dashboard that should show partial data rather than completely failing because one widget's API timed out. Choosing the wrong one leads to either silent partial failures or unnecessarily broken UIs.
AspectPromise .catch() Chainingasync/await try/catch
ReadabilityModerate — chains can get deeply nestedHigh — reads like synchronous code
Error origin clarityCan be unclear which step failed in long chainsStack trace points to exact await line
Multiple operationsRequires careful chain constructionSequential steps are naturally clear
Parallel executionNatural fit with Promise.all().catch()Works but requires Promise.all wrapping
Partial error handlingEach .catch() in chain catches what's above itMultiple try/catch blocks needed
Works in non-async functionsYes — any function can return a Promise chainNo — requires the function to be async
Linting / enforcementFloating .catch() easy to missESLint no-floating-promises catches missed awaits
Best forUtility functions, transformations, simple asyncBusiness logic, route handlers, complex flows

🎯 Key Takeaways

    ⚠ Common Mistakes to Avoid

    • Mistake 1: Catching errors but not returning after them in callbacks — Symptom: code continues executing after the error branch, causing 'Cannot set headers after they are sent' errors in Express or processing data that doesn't exist — Fix: always return immediately after handling an error: if (err) { handleError(err); return; } — treat it as a mandatory two-line pattern, never one.
    • Mistake 2: Using try/catch around an async function call without awaiting it — Symptom: the try/catch block never triggers even though the async function clearly throws; the error surfaces later as an UnhandledPromiseRejection — Fix: try { await asyncFunction(); } not try { asyncFunction(); } — without await, you're catching the synchronous act of calling the function, not its eventual rejection.
    • Mistake 3: Registering the Express global error handler before your routes — Symptom: route errors are never formatted correctly; the 4-argument middleware never runs — Fix: the global error handler must be the very last app.use() call, after all routes and other middleware. Express processes middleware in registration order, so if the error handler comes first, errors from routes never reach it.
    🔥
    TheCodeForge Editorial Team Verified Author

    Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.

    ← PreviousReact Suspense and Lazy LoadingNext →TypeScript Declaration Files
    Forged with 🔥 at TheCodeForge.io — Where Developers Are Forged