DEV Community

Ansh Sheladiya
Ansh Sheladiya

Posted on

Node.js Performance Optimization: Practical Techniques for Faster APIs

Node.js is excellent at handling I/O-heavy workloads, but good performance does not happen automatically. As an application grows, inefficient database access, unnecessary CPU work, excessive memory usage, and poorly managed asynchronous operations can quickly become bottlenecks.

The good news is that most Node.js performance problems can be identified and improved with practical engineering techniques. Profiling first, reducing unnecessary work, controlling concurrency, caching expensive operations, and monitoring event-loop behavior can make a significant difference without rewriting the entire application.

Practical Node.js Performance Optimization Techniques

Performance optimization should begin with measurement rather than assumptions. Node.js provides useful built-in tools such as process.hrtime.bigint(), process.memoryUsage(), and perf_hooks that allow developers to measure execution time, memory consumption, and event-loop behavior directly inside an application.

One common optimization is reducing unnecessary repeated work. In the example below, an intentionally expensive calculation is performed for multiple requests, while a simple in-memory cache prevents the same value from being calculated repeatedly. This pattern is useful for expensive computations, configuration lookups, or frequently requested data that does not change often.

Concurrency is another important consideration. Running thousands of asynchronous operations at once can overwhelm external APIs, databases, or the application itself. A controlled concurrency strategy keeps resource usage predictable while still allowing independent operations to execute in parallel.

The following example combines several useful techniques: high-resolution timing, caching, controlled concurrency, memory inspection, and structured performance logging. Run it with Node.js and compare the first execution of the expensive operation with subsequent cached executions to see how much unnecessary work can be eliminated.

const { performance, monitorEventLoopDelay } = require("node:perf_hooks");

console.log("[1] Starting Node.js performance optimization demo...");

const cache = new Map();
const eventLoopMonitor = monitorEventLoopDelay({ resolution: 20 });
eventLoopMonitor.enable();

function formatBytes(bytes) {
  return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
}

function logMemory(label) {
  const memory = process.memoryUsage();
  console.log(`\n[MEMORY] ${label}`);
  console.log(`RSS: ${formatBytes(memory.rss)}`);
  console.log(`Heap Used: ${formatBytes(memory.heapUsed)}`);
  console.log(`Heap Total: ${formatBytes(memory.heapTotal)}`);
}

function expensiveCalculation(input) {
  console.log(`[WORK] Calculating expensive result for ${input}...`);
  let result = 0;

  for (let index = 0; index < 8_000_000; index++) {
    result += Math.sqrt(index + input);
  }

  return Math.round(result);
}

function getCachedResult(input) {
  if (cache.has(input)) {
    console.log(`[CACHE] Returning cached result for ${input}`);
    return cache.get(input);
  }

  console.log(`[CACHE] Cache miss for ${input}`);
  const result = expensiveCalculation(input);
  cache.set(input, result);
  return result;
}

function measure(label, operation) {
  const start = performance.now();
  const result = operation();
  const duration = performance.now() - start;
  console.log(`[TIMER] ${label}: ${duration.toFixed(2)} ms`);
  return result;
}

async function simulateRequest(id, delay = 50) {
  await new Promise((resolve) => setTimeout(resolve, delay));
  console.log(`[REQUEST] Completed simulated request ${id}`);
  return `response-${id}`;
}

async function runWithConcurrency(tasks, limit) {
  const results = new Array(tasks.length);
  let nextIndex = 0;

  async function worker(workerId) {
    while (true) {
      const currentIndex = nextIndex++;
      if (currentIndex >= tasks.length) return;

      console.log(`[WORKER ${workerId}] Processing task ${currentIndex + 1}`);
      results[currentIndex] = await tasks[currentIndex]();
    }
  }

  const workers = Array.from(
    { length: Math.min(limit, tasks.length) },
    (_, index) => worker(index + 1)
  );

  await Promise.all(workers);
  return results;
}

async function main() {
  console.log("[2] Inspecting initial memory usage...");
  logMemory("Before optimization demo");

  console.log("\n[3] Running expensive calculation without cache...");
  measure("First calculation", () => expensiveCalculation(42));

  console.log("\n[4] Running the same calculation through the cache...");
  measure("Cache population", () => getCachedResult(42));
  measure("Cached calculation", () => getCachedResult(42));

  console.log("\n[5] Demonstrating controlled asynchronous concurrency...");
  const tasks = Array.from({ length: 10 }, (_, index) => {
    return () => simulateRequest(index + 1, 75);
  });

  const start = performance.now();
  const responses = await runWithConcurrency(tasks, 3);
  const duration = performance.now() - start;

  console.log(`[CONCURRENCY] Completed ${responses.length} tasks`);
  console.log(`[CONCURRENCY] Total time: ${duration.toFixed(2)} ms`);
  console.log("[CONCURRENCY] Maximum active workers: 3");

  console.log("\n[6] Checking final memory usage...");
  logMemory("After optimization demo");

  console.log("\n[7] Checking event-loop delay...");
  console.log(
    `[EVENT LOOP] Mean delay: ${(eventLoopMonitor.mean / 1e6).toFixed(2)} ms`
  );
  console.log(
    `[EVENT LOOP] Maximum delay: ${(eventLoopMonitor.max / 1e6).toFixed(2)} ms`
  );

  eventLoopMonitor.disable();

  console.log("\n[8] Performance demo completed successfully.");
  console.log("[SUMMARY] Measure first, optimize bottlenecks, then measure again.");
}

main().catch((error) => {
  console.error("[ERROR] Performance demo failed:", error);
  process.exitCode = 1;
});
Enter fullscreen mode Exit fullscreen mode

Conclusion

Node.js performance optimization is mostly about eliminating unnecessary work and using resources intentionally. Caching repeated computations, controlling asynchronous concurrency, monitoring memory, and measuring execution time are simple techniques that can produce meaningful improvements in real applications.

However, optimization should always be driven by measurements. Use profiling and production metrics to identify the actual bottleneck before changing code, because optimizing code that is already fast can add complexity without improving the user experience.

For larger Node.js systems, continue monitoring CPU usage, heap growth, garbage collection, event-loop latency, database response times, and external API latency. A performance-focused development process is not about making every function faster; it is about keeping the entire system responsive, predictable, and scalable as traffic increases.

Top comments (0)