Lesson 17-Node Error Handling and Performance Optimization

Exception Types

Node.js exceptions are primarily divided into two categories:

  • Synchronous Exceptions: Typically triggered by throw statements.
  • Asynchronous Exceptions: Caused by failures in asynchronous operations (e.g., file I/O, network requests).

Exception Objects

In Node.js, exceptions are encapsulated as Error objects, which include detailed information such as the error name, message, and stack trace. Common Error subclasses include:

  • Error: The base error class.
  • RangeError: Thrown when a value exceeds its valid range.
  • ReferenceError: Thrown when referencing an undefined variable.
  • SyntaxError: Thrown for code syntax errors.
  • TypeError: Thrown when an operand type is incorrect.
  • URIError: Thrown for URI encoding or decoding errors.
  • EvalError: Thrown by the eval() function.
  • AggregateError: Represents a collection of errors.
  • InternalError: Internal errors, such as memory exhaustion.

Exception Handling

Synchronous Exception Handling

Use try...catch statements to handle synchronous exceptions.

try {
  // Code that may throw an exception
  throw new Error('An error occurred');
} catch (err) {
  console.error('Caught an exception:', err.message);
}

Asynchronous Exception Handling

Asynchronous exceptions are typically handled in callback functions.

fs.readFile('nonexistentfile.txt', (err, data) => {
  if (err) {
    console.error('File read error:', err);
    return;
  }
  // Process file data
});

Asynchronous Error Handling

Promise Error Handling

Promises provide an elegant way to handle asynchronous errors.

Promise.reject(new Error('Promise rejected'))
  .catch(err => console.error('Promise error:', err));

Async/Await Error Handling

async/await is syntactic sugar for Promises, simplifying asynchronous error handling.

async function loadFile() {
  try {
    const data = await fs.promises.readFile('file.txt', 'utf8');
    console.log(data);
  } catch (err) {
    console.error('File load error:', err);
  }
}

Global Exception Handling

uncaughtException Event

Listen to the uncaughtException event on the process object to capture unhandled exceptions.

process.on('uncaughtException', (err) => {
  console.error('Uncaught Exception:', err);
  // Log errors, send reports, etc.
});

unhandledRejection Event

Listen to the unhandledRejection event to capture unhandled Promise rejections.

process.on('unhandledRejection', (reason, promise) => {
  console.error('Unhandled Rejection at:', promise, 'reason:', reason);
});

Custom Error Codes

Custom error codes can be defined to differentiate error types, aiding in error handling and debugging.

class CustomError extends Error {
  constructor(message, code) {
    super(message);
    this.code = code;
    this.name = this.constructor.name;
  }
}

try {
  throw new CustomError('Custom error message', 500);
} catch (err) {
  if (err instanceof CustomError) {
    console.error('Custom Error:', err.message, 'Code:', err.code);
  }
}

Error Logging and Reporting

In production environments, detailed error logs should be recorded and error reports sent for tracking and resolution.

const winston = require('winston');

const logger = winston.createLogger({
  level: 'info',
  format: winston.format.json(),
  transports: [
    new winston.transports.File({ filename: 'error.log' })
  ],
});

logger.error('An error occurred', { error: err });

Performance Optimization

CPU Optimization

Avoiding Blocking Calls

Node.js excels with its non-blocking I/O model. Avoid blocking calls like fs.readFileSync and use asynchronous alternatives like fs.readFile.

Reducing Compute-Intensive Tasks

Break compute-intensive tasks into smaller chunks, leveraging the event loop’s idle time to avoid prolonged CPU usage.

Using Worker Threads

Since Node.js v10.5.0, the Worker Threads API enables parallel JavaScript execution outside the main thread.

const { Worker, isMainThread, parentPort } = require('worker_threads');

if (isMainThread) {
  const worker = new Worker(__filename);
  worker.on('message', (result) => {
    console.log(result);
  });
} else {
  const result = /* perform heavy computation */;
  parentPort.postMessage(result);
}

Memory Optimization

Avoiding Memory Leaks

  • Circular References: Ensure unused objects are garbage-collected.
  • Timers and Event Listeners: Clear timers and event listeners when no longer needed.

Caching Strategies

Use caching to reduce redundant computations and I/O, but manage cache size and expiration to avoid excessive memory usage.

Compression and Serialization

Use compression algorithms (e.g., gzip) and efficient serialization formats (e.g., protobuf) to minimize memory and network usage.

Asynchronous Operation Optimization

Avoiding Callback Hell

Use Promises and async/await instead of nested callbacks for clearer, maintainable code.

Controlling Concurrency

Limit concurrent operations to prevent resource exhaustion, using Promise.allSettled or custom concurrency controllers.

function processItems(items, handler, maxConcurrency) {
  const results = [];
  let active = 0;
  let next = 0;

  const loop = () => {
    while (next < items.length && active < maxConcurrency) {
      active++;
      const item = items[next++];
      handler(item)
        .then(result => {
          results.push(result);
          active--;
          if (active < maxConcurrency && next < items.length) {
            loop();
          }
        })
        .catch(err => {
          // Handle error
        });
    }
  };

  loop().then(() => {
    // All items processed
  });
}

Using the Cluster Module for Multi-Process Handling

Cluster Module Overview

The Cluster module creates child processes, each with its own event loop and memory space, leveraging multi-core CPUs.

Creating a Master Process

const cluster = require('cluster');
const http = require('http');
const numCPUs = require('os').cpus().length;

if (cluster.isMaster) {
  console.log(`Master ${process.pid} is running`);

  for (let i = 0; i < numCPUs; i++) {
    cluster.fork();
  }

  cluster.on('exit', (worker, code, signal) => {
    console.log(`Worker ${worker.process.pid} died`);
  });
} else {
  // Workers can share any TCP connection
  // In this case, it is an HTTP server
  http.createServer((req, res) => {
    res.writeHead(200);
    res.end('Hello World\n');
  }).listen(8000);

  console.log(`Worker ${process.pid} started`);
}

Advanced Optimization Strategies

Using V8 Performance Tools

The V8 engine provides tools like CPU profiler, heap profiler, and timeline profiler to identify performance bottlenecks.

# Start CPU profiler
node --trace-turbo-inlining --trace-deopt --trace-opt --trace-gc --trace-ic --expose-gc yourapp.js

# Generate heap snapshot
node --expose-gc yourapp.js > heap-snapshot.heapsnapshot

Analyze .heapsnapshot files in Chrome DevTools to inspect memory usage.

JIT Compilation Optimization

Understand V8’s Just-In-Time (JIT) compiler mechanisms, such as inline caching (ICs), type feedback, and optimizing compilation, to write more efficient code.

Reducing GC Pressure

Frequent garbage collection impacts performance. Reduce object allocations, use weak references, and clear unused objects to lower GC frequency.

Network Optimization

Using Keep-Alive

HTTP Keep-Alive reuses TCP connections, reducing connection setup and teardown overhead.

Compressing Responses

Use gzip or Brotli to compress response data, minimizing network transfer size.

Caching Strategies

Set HTTP cache headers like Cache-Control and Expires to reduce unnecessary requests.

File System Optimization

Asynchronous Operations

Always use asynchronous file system methods (e.g., fs.readFile, fs.writeFile) to avoid blocking the event loop.

Streaming Operations

Use streaming interfaces (e.g., fs.createReadStream, fs.createWriteStream) for large files to avoid loading them entirely into memory.

Database Optimization

Connection Pooling

Use connection pools to manage database connections, avoiding frequent connection establishment and termination.

Query Optimization

Optimize SQL queries with indexes to avoid full table scans.

ORM vs. Raw SQL

Choose between ORM and raw SQL based on needs; ORMs offer convenience but may introduce performance overhead.

Performance Monitoring and Tuning

Using PM2

PM2 is a robust process manager that monitors application health, offering features like restarts, logging, and load balancing.

New Relic / Datadog

Use professional APM tools like New Relic or Datadog to monitor performance metrics, including CPU, memory, network, and database activity.

Share your love