Blog

Node.js Performance Tips

Key Takeaways

  • Measure throughput, tail latency, errors, CPU, and memory before changing code.
  • Keep synchronous I/O and expensive JavaScript off the request path; asynchronous syntax alone doesn't make computation parallel.
  • Reduce database calls, serialization, and transferred bytes before adding application instances.
  • Use CDN caching and compression where they fit your content and deployment, not as automatic fixes.
  • Replace Express session MemoryStore in production, and choose processes, worker threads, or background jobs for the workload you actually have.

Node.js performance optimization starts with finding what makes requests wait: blocked JavaScript execution, slow dependencies, excessive work, or insufficient capacity. These Node.js performance tips focus on removing that bottleneck, then checking the result under representative traffic—not assuming a particular library or deployment pattern makes everything faster.

Establish a Performance Baseline

Choose an important request flow and define an acceptable latency and error rate. Measure requests per second, p50, p95, and p99 latency, CPU usage, memory, event-loop delay, and downstream timings. Percentiles expose the slow end of the distribution that an average can hide.

  1. Reproduce realistic payloads, authentication, database sizes, and cache-hit rates.
  2. Warm up the application, then test steady traffic and bursts.
  3. Capture a profile while reproducing the problem.
  4. Change one thing and repeat with the same workload and resource limits.

For a local smoke test, start the Express example below, then run Autocannon in another terminal:

npm install --save-dev autocannon
npx autocannon -c 50 -d 30 --latency http://127.0.0.1:3000/data

This uses 50 concurrent connections for 30 seconds and prints detailed latency percentiles. Record requests per second, p50, p99, errors, and non-success responses before and after your change. A local test is a starting point; use a separate load generator and realistic dependencies for capacity testing.

Browser
  |
  v
CDN [cache hit: origin work avoided]
  | cache miss / dynamic request
  v
Reverse proxy [routing, optional compression]
  |
  v
Node.js [JavaScript, serialization, event-loop delay]
  |                         |
  v                         v
Database / APIs           Shared session store
[query, network, pool]     [latency, TTL, availability]

Capture and Interpret a CPU Profile

Start your entry point with the Node.js inspector; substitute your application filename or use the demo below:

node --inspect=127.0.0.1:9229 app.mjs
  1. Open chrome://inspect and inspect the Node.js target.
  2. In the Performance panel, start recording, exercise the slow flow, and stop recording.
  3. Inspect Bottom-Up for expensive activities and Call Tree for the callers responsible for them.

Chrome's Node.js profiling guide explains this workflow. In its timing views, self time is time spent directly in an activity; total time includes its children. Look for repeated parsing, serialization, rendering, or transformation work rather than optimizing a function merely because its caller appears expensive.

For file-based collection, Node's --cpu-prof option starts the V8 CPU profiler at startup and writes a profile before normal process exit. A diagnostic report instead captures process state for troubleshooting crashes, hangs, and resource problems; it isn't a CPU profile or continuous performance monitor.

Avoid Blocking the Node.js Event Loop

The event loop coordinates JavaScript callbacks and I/O completion. Long-running JavaScript, synchronous filesystem calls, and expensive regular expressions can prevent it from handling other requests. Node's blocking guide covers these hazards; our explanation of the Node.js event loop provides the underlying model.

Measure Event-Loop Delay and Utilization

monitorEventLoopDelay() samples event-loop scheduling delay, while performance.eventLoopUtilization() measures active versus idle event-loop time—not CPU utilization. Add this fragment once to a long-running server entry point:

// Fragment: add to your server entry point.
import { monitorEventLoopDelay, performance } from 'node:perf_hooks';

const delay = monitorEventLoopDelay({ resolution: 20 });
delay.enable();
let previous = performance.eventLoopUtilization();

setInterval(() => {
  const current = performance.eventLoopUtilization();
  const interval = performance.eventLoopUtilization(current, previous);
  previous = current;

  console.log({
    eventLoopP99Ms: delay.percentile(99) / 1e6,
    eventLoopUtilization: interval.utilization,
  });
  delay.reset();
}, 5000).unref();

The histogram reports nanoseconds, so the example converts to milliseconds and resets the measurement window. Interval-based samples include the configured sampling interval: compare with an idle baseline using the same configuration, not zero. In production, export these measurements through your metrics pipeline rather than relying on console output.

  • Delay and CPU rise together: investigate JavaScript hotspots, garbage collection, and CPU contention.
  • Requests slow down while loop delay stays near baseline: investigate database calls, network waits, and connection-pool queues.
  • Utilization rises without matching CPU usage: investigate synchronous waits; utilization alone doesn't prove CPU saturation.

These are investigation starting points, not diagnoses. Correlate profiles, dependency timings, container CPU throttling, and per-instance traffic.

Measure Logging Overhead Too

console.log() isn't always synchronous or always asynchronous. Node's process I/O documentation explains that stdout and stderr behavior depends on the destination and operating system.

High-volume logging can still add formatting, serialization, I/O, and buffering costs. Measure with production log settings, avoid large object dumps on hot paths, redact secrets, and configure bounded buffering and an explicit overload policy for your logger or centralized collection pipeline.

Reduce Database, Network, and Serialization Work

Time each dependency operation separately, including time spent waiting for a connection. A connection pool reuses connections, but requests can still queue when none are available; MongoDB's Node.js connection-pool guide documents this distinction.

  • Remove N+1 queries: fetching a list and then making another query for every item.
  • Inspect query plans and indexes, and return only the fields you need.
  • Reuse database clients and configure pool sizes against total deployment capacity, not one instance in isolation.
  • Bound concurrent downstream operations and queued work. Launching every operation at once can overwhelm a dependency.
  • Set dependency deadlines and retry budgets; avoid unlimited retries during overload.
  • Paginate large responses and avoid repeatedly parsing or serializing unchanged data.

Cache expensive results when reuse justifies it, but define the key, expiration, invalidation rules, and memory budget first. Include tenant and authorization boundaries in the design; an incorrect cache is a correctness or security defect, not an optimization.

Use CDNs for Cacheable Static Assets

A CDN can serve cached scripts, stylesheets, images, and other assets without forwarding each request to your Node.js origin. The origin-load benefit depends on cache hits; moving a URL behind a CDN doesn't itself eliminate application work.

Follow HTTP caching rules: use content-hashed asset filenames with a long freshness lifetime and immutable, then publish a new URL when content changes. Give HTML and frequently changing resources an appropriate revalidation or shorter-lived policy.

Keep personalized responses out of shared caches unless you've explicitly designed safe cache keys and policies. Cache-Control: private excludes shared-cache storage; no-store prohibits storage. If your hosting platform already serves assets through an edge network, inspect its headers and hit rate before adding another CDN.

Choose Where and When to Compress Responses

Compression trades CPU work for fewer transferred bytes. Negotiate gzip or Brotli through Accept-Encoding; neither is universally best. Compare actual response types, compression settings, CPU usage, and end-to-end latency, especially for small or already-compressed payloads.

For Express-side compression, create an empty directory and install the dependencies:

npm init -y
npm install express compression

Save this demonstration server as app.mjs:

import express from 'express';
import compression from 'compression';

const app = express();
app.use(compression({ threshold: 1024 }));

app.get('/data', (_req, res) => {
  res.json({ message: 'hello '.repeat(1000) });
});

app.listen(3000, '127.0.0.1').on('error', (error) => {
  console.error(error);
  process.exitCode = 1;
});
node app.mjs

The compression middleware considers compressible response types, client-supported encodings, and the size threshold. The configured 1,024-byte threshold is advisory when response length is unknown, and the middleware respects Cache-Control: no-transform.

Test negotiated compression by adding -H 'Accept-Encoding=gzip' to the Autocannon command. Also test realistic network conditions: a localhost throughput result alone doesn't measure bandwidth savings for remote users.

Express's production guidance recommends moving compression to a reverse proxy for high-traffic deployments. If the proxy or CDN owns compression, avoid configuring duplicate compression work, preserve correct Vary: Accept-Encoding behavior, and verify that streaming responses aren't delayed by buffering.

Use a Production Session Store

The default store in express-session, MemoryStore, isn't designed for production: it doesn't scale beyond one process and can leak memory. A shared store makes sessions accessible across instances, but doesn't guarantee availability or low latency.

connect-redis provides Redis-backed storage; connect-mongo provides MongoDB-backed storage. Choose based on measured latency, existing operations expertise, persistence requirements, failover behavior, and connection limits—not the adapter name alone.

  • Keep session data small; configure expiration, or TTL, and verify its interaction with cookie lifetime and activity-based renewal.
  • Set saveUninitialized: false to avoid storing new, unchanged sessions; use resave: false when the store supports the required touch behavior.
  • Keep signing secrets outside source control and consistent across instances.
  • Use HttpOnly, HTTPS-only Secure cookies, and an explicit SameSite policy appropriate to your authentication flow.
  • Configure proxy trust to match your actual deployment when TLS terminates upstream.

Test store outages and eviction behavior, and monitor read/write latency. Review the broader Express security best practices alongside performance changes.

Choose Rendering for Users, Not Just Server CPU

React or Vue client-side rendering can reduce server rendering work, but useful content may wait for JavaScript download, parsing, execution, and data fetching. Server-side rendering moves initial rendering work to the server; hydration then connects browser-side behavior to that HTML.

Vue's rendering guide explains the trade-off between server cost, time to content, and static generation. Static generation produces HTML ahead of requests and suits content that can tolerate a build or regeneration cycle. React's streaming server renderer can send a shell before all content is ready, but doesn't eliminate rendering or hydration work.

Compare useful-content timing, interaction readiness, search visibility, cacheability, and server cost on representative devices. Our client-side versus server-side rendering comparison expands on that decision.

Optimize Measured JavaScript Hotspots, Not Syntax

Use async/await and ES modules for clear control flow and compatibility—not as automatic speed improvements. Likewise, replacing Lodash helpers with native methods isn't inherently faster: compare equivalent behavior in your workload.

Start with algorithms, repeated computation, unnecessary allocations, and oversized data structures. Only investigate engine-specific details after profiling identifies a hot path; the guide to optimizing JavaScript for V8 provides that deeper context.

Match Node.js Parallelism to the Workload

Asynchronous I/O lets requests overlap while waiting; it doesn't make CPU-heavy JavaScript execute on additional cores. Wrapping a computation in a promise doesn't change that.

ApproachWhat it providesGood fitMain consideration
Asynchronous I/OOverlapping I/O waitsDatabase and network operationsBound concurrency and dependency load
worker_threadsParallel JavaScript execution in threadsCPU-intensive computationsUse a pool; account for startup and data-transfer costs
clusterWorker processes that can share server portsMultiple application processes on a hostManage worker lifecycle and externalize shared state
Multiple deployed instancesIndependent processes behind a load balancerHorizontal request capacity and deployment isolationCoordinate health, draining, and downstream capacity

Node's worker-thread documentation recommends pooling workers rather than creating one per task. The cluster module remains an option, but distributing requests across processes doesn't automatically make an individual request faster.

For long jobs that don't need an immediate result, consider a bounded job queue and separate worker processes or services. Benchmark the actual topology, including serialization overhead, CPU quotas, memory per process, and the database connections added by every instance.

Watch Memory and Garbage Collection Under Load

Track heap usage alongside total process memory. Node's memory guide explains these measurements and V8 garbage collection, which reclaims unreachable objects. Investigate retained growth across comparable load cycles rather than treating every temporary heap increase as a leak.

Bound caches and queues, release unused references, and include a sustained-load test. Increasing the heap limit doesn't fix a leak.

Production Checklist

  • Use a maintained Active LTS or Maintenance LTS runtime, following the Node.js release schedule.
  • Run under a process supervisor or orchestrator, with resource limits and a tested restart policy.
  • Implement health checks and graceful shutdown: stop routing new traffic, drain requests with a deadline, close dependencies, and exit.
  • Monitor latency percentiles, error rates, dependency waits, event-loop behavior, memory, and queue depth per instance.
  • Keep representative regression tests, validate response correctness, and compare unprofiled runs before accepting a performance improvement.

FAQ

How do I profile a Node.js application in production?

Capture a short CPU profile on a controlled instance using the inspector or an approved profiling integration while the problem occurs. Restrict inspector access, measure profiling overhead, and use diagnostic reports separately for process-state investigations.

How can I detect event-loop blocking in Node.js?

Measure scheduling delay with monitorEventLoopDelay() and compare it with CPU usage, event-loop utilization, and request latency. An increase in delay is a signal to investigate; a CPU profile and dependency timings help distinguish expensive JavaScript from other causes.

When should I use worker_threads instead of cluster?

Use a worker-thread pool to move CPU-intensive JavaScript off the request-handling thread. Use cluster or independently managed application instances to distribute requests across processes; ordinary asynchronous I/O usually doesn't need worker threads.

Is Express's MemoryStore safe for production?

No. The default express-session MemoryStore isn't designed for production and doesn't share sessions across processes. Use an appropriate shared store and test its expiration, latency, security, and failure behavior.

Is asynchronous JavaScript always better than synchronous code?

No. Small, bounded synchronous configuration or initialization tasks can be appropriate before the service accepts traffic. On request paths, avoid blocking I/O and lengthy computation; async/await alone doesn't make CPU-heavy work parallel.

Further Reading

Written by Sam Erickson

Software engineer. I write every article on StackChief myself. Author of Mastering the Tech Interview. More about me