Asher Cohen
Back to posts

Error Handling in Node.js: Synchronous and Asynchronous Patterns

Complete guide to error handling in Node.js — try-catch, callbacks, promises, async/await, and uncaught exception handling

Introduction

Error handling is critical in Node.js applications. Unlike browser JavaScript, an unhandled error in Node.js can crash your entire server. Understanding both synchronous and asynchronous error handling patterns is essential for building reliable backend applications.

Synchronous Error Handling

Try-Catch

The standard pattern for synchronous code:

function parseJSON(jsonString) {
  try {
    const data = JSON.parse(jsonString);
    return { success: true, data };
  } catch (error) {
    return { success: false, error: error.message };
  }
}

const result = parseJSON('{"valid": true}');
console.log(result); // { success: true, data: { valid: true } }

const badResult = parseJSON('{invalid}');
console.log(result); // { success: false, error: 'Unexpected token...' }

Throwing Custom Errors

class ValidationError extends Error {
  constructor(message, field) {
    super(message);
    this.name = 'ValidationError';
    this.field = field;
  }
}

class DatabaseError extends Error {
  constructor(message, query) {
    super(message);
    this.name = 'DatabaseError';
    this.query = query;
  }
}

function validateUser(user) {
  if (!user.email) {
    throw new ValidationError('Email is required', 'email');
  }
  if (!user.name) {
    throw new ValidationError('Name is required', 'name');
  }
}

try {
  validateUser({ email: 'test@example.com' });
} catch (error) {
  if (error instanceof ValidationError) {
    console.error(`Validation failed on ${error.field}: ${error.message}`);
  } else {
    throw error; // Re-throw unknown errors
  }
}

Asynchronous Error Handling

The Callback Pattern (Legacy)

import fs from 'node:fs';

// Error-first callback pattern
fs.readFile('config.json', 'utf8', (error, data) => {
  if (error) {
    console.error('Failed to read config:', error.message);
    return;
  }
  
  try {
    const config = JSON.parse(data);
    console.log('Config loaded:', config);
  } catch (parseError) {
    console.error('Invalid config format:', parseError.message);
  }
});

// Always check the error parameter first!

Promises

import fs from 'node:fs/promises';

// Promise chain with catch
fs.readFile('data.json', 'utf8')
  .then(data => JSON.parse(data))
  .then(config => {
    console.log('Config:', config);
  })
  .catch(error => {
    if (error.code === 'ENOENT') {
      console.error('File not found');
    } else if (error instanceof SyntaxError) {
      console.error('Invalid JSON');
    } else {
      console.error('Unexpected error:', error);
    }
  });

Async/Await (Modern, Preferred)

import fs from 'node:fs/promises';

async function loadConfig() {
  try {
    const data = await fs.readFile('config.json', 'utf8');
    const config = JSON.parse(data);
    return config;
  } catch (error) {
    if (error.code === 'ENOENT') {
      console.error('Config file not found, using defaults');
      return { port: 3000, host: 'localhost' };
    }
    
    if (error instanceof SyntaxError) {
      console.error('Invalid config JSON');
      throw new Error('Configuration is corrupted');
    }
    
    throw error; // Re-throw unexpected errors
  }
}

// Usage
const config = await loadConfig();

Handling Multiple Async Operations

// Promise.all — fail if any reject
try {
  const [users, posts, comments] = await Promise.all([
    fetchUsers(),
    fetchPosts(),
    fetchComments()
  ]);
} catch (error) {
  console.error('Failed to load dashboard:', error);
}

// Promise.allSettled — handle each result individually
const results = await Promise.allSettled([
  fetchUsers(),
  fetchPosts(),
  fetchComments()
]);

results.forEach((result, index) => {
  if (result.status === 'fulfilled') {
    console.log(`Operation ${index} succeeded`);
  } else {
    console.error(`Operation ${index} failed:`, result.reason);
  }
});

Global Error Handling

Uncaught Exceptions

// Catches synchronous errors that escape try-catch
process.on('uncaughtException', (error) => {
  console.error('Uncaught exception:', error);
  // Perform cleanup, then exit
  server.close(() => process.exit(1));
  
  // Always exit — the process is in an unknown state
});

Unhandled Promise Rejections

// Catches promises that reject without a catch handler
process.on('unhandledRejection', (reason, promise) => {
  console.error('Unhandled rejection:', reason);
  // Log, alert, but don't necessarily crash
});

Express Error Middleware

import express from 'express';

const app = express();

// Route handler
app.get('/users/:id', async (req, res, next) => {
  try {
    const user = await fetchUser(req.params.id);
    res.json(user);
  } catch (error) {
    next(error); // Pass to error middleware
  }
});

// Error handling middleware (4 parameters)
app.use((error, req, res, next) => {
  console.error('Error:', error.message);
  
  if (error instanceof ValidationError) {
    return res.status(400).json({ error: error.message, field: error.field });
  }
  
  if (error instanceof DatabaseError) {
    return res.status(500).json({ error: 'Internal server error' });
  }
  
  res.status(500).json({ error: 'Something went wrong' });
});

Error Handling Patterns

Result Pattern

// Return result objects instead of throwing
async function fetchUser(id) {
  try {
    const response = await fetch(`/api/users/${id}`);
    if (!response.ok) {
      return { success: false, error: `HTTP ${response.status}` };
    }
    const data = await response.json();
    return { success: true, data };
  } catch (error) {
    return { success: false, error: error.message };
  }
}

// Usage
const result = await fetchUser(1);
if (result.success) {
  console.log(result.data);
} else {
  console.error(result.error);
}

Retry Pattern

async function withRetry(fn, maxRetries = 3, delay = 1000) {
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      return await fn();
    } catch (error) {
      if (attempt === maxRetries) throw error;
      
      console.warn(`Attempt ${attempt} failed, retrying in ${delay}ms...`);
      await new Promise(resolve => setTimeout(resolve, delay));
      delay *= 2; // Exponential backoff
    }
  }
}

// Usage
const data = await withRetry(() => fetch('https://api.example.com/data'));

Graceful Shutdown

async function shutdown(server) {
  console.log('Shutting down gracefully...');
  
  // Stop accepting new connections
  server.close();
  
  // Close database connections
  await db.disconnect();
  
  // Flush logs
  await logger.flush();
  
  console.log('Shutdown complete');
  process.exit(0);
}

process.on('SIGTERM', () => shutdown(server));
process.on('SIGINT', () => shutdown(server));

Best Practices

Do's

// ✅ Always handle promise rejections
await operation().catch(handleError);

// ✅ Use custom error classes
throw new ValidationError('Invalid input', 'email');

// ✅ Log errors with context
console.error('User fetch failed', { userId: id, error: error.message });

// ✅ Handle specific errors differently
if (error.code === 'ENOENT') { /* file not found */ }

// ✅ Use async/await with try-catch
try { await operation(); } catch (error) { /* ... */ }

Don'ts

// ❌ Don't swallow errors silently
try { await operation(); } catch (error) {} // Empty catch!

// ❌ Don't throw strings
throw 'Something failed'; // Use Error objects

// ❌ Don't ignore the error in callbacks
fs.readFile('file.txt', (err, data) => {
  console.log(data); // What if err?
});

// ❌ Don't use sync methods in async contexts
const data = fs.readFileSync('file.txt'); // Blocks event loop!

// ❌ Don't crash on expected errors
// Only crash on unrecoverable errors

Summary Table

PatternUse CaseExample
try-catchSynchronous codetry { JSON.parse() } catch {}
Error-first callbackLegacy Node.js APIs(err, data) => {}
Promise.catch()Promise chains.then().catch()
async/await + try-catchModern async codetry { await fn() } catch {}
uncaughtExceptionLast-resort handlerprocess.on('uncaughtException')
Express middlewareHTTP error responsesapp.use((err, req, res, next) => {})

Conclusion

Robust error handling is what separates production applications from prototypes:

  • Use async/await with try-catch for modern code
  • Create custom error classes for domain-specific errors
  • Handle errors at the right level — don't catch what you can't handle
  • Log with context — error messages alone aren't enough
  • Always have global handlers for uncaught exceptions and rejections

An unhandled error in Node.js can crash your server. Handle errors deliberately and your application will be resilient.

#nodejs #error-handling #javascript #async #backend #web-development