Performance Best Practices
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Express 5.3 (npm express 5.3.0, published 2026-10-09) on Node.js 18 or later, the minimum
for Express 5, and it states where Express 4 (latest 4.22.3) behaves differently. Everything was checked against the
official documentation and the package repositories on 2026-10-29. The official performance and reliability page lists
a short set of recommendations; this page follows them point by point and adds what they leave implicit: how to measure
before changing anything, what each technique costs, and how the pieces combine in a production deployment of the
Bookshelf API that runs through the whole Express.js section. The examples use ESM and the node: prefix for
built-in modules, and contain placeholders only, never real secrets. The event-loop background is in
Don’t Block the Event Loop and
Profiling and Performance; process-level scaling is covered
framework-free in Cluster and Scaling and
Worker Threads.
The official recommendations at a glance
The official page splits its advice into two groups: things to do in the code and things to do in the environment. Express itself adds very little overhead; most performance problems are caused by blocking the single JavaScript thread, by sending more bytes than necessary, by recomputing what could be cached, or by running one process on a machine with many cores.
| Area | Recommendation | Where it is covered below |
|---|---|---|
Code |
Use gzip (or Brotli) compression |
|
Code |
Do not use synchronous functions |
|
Code |
Do logging correctly |
|
Code |
Handle exceptions properly |
|
Code |
Offload CPU-bound work |
|
Environment |
Set |
|
Environment |
Ensure your app automatically restarts |
|
Environment |
Run your app in a cluster |
|
Environment |
Cache request results |
|
Environment |
Use a load balancer and a reverse proxy |
|
Method |
Measure with a load generator before and after |
Measure first
Every technique on this page trades complexity for speed, so apply none of them without a baseline. A useful baseline
has three numbers: requests per second at a fixed concurrency, latency percentiles (p50, p97.5 and p99, not the
average), and event-loop delay or CPU usage while the test runs. Section Measuring with autocannon shows how to take them. Two rules
keep the numbers honest: run the load generator on a different machine (or at least a different set of cores) than the
server, and test the production configuration (NODE_ENV=production, real logging, a real database), because a
benchmark of a toy app with logging disabled predicts nothing.
Compression
Compressing text responses (HTML, JSON, CSS, JavaScript, SVG) usually shrinks them by 70 percent or more and is the cheapest win for clients on slow networks. There are two places to do it.
At the reverse proxy (preferred)
The official guidance is to compress at the reverse proxy when you have one: Nginx, Caddy, HAProxy and the cloud
load balancers do it with native code and keep the CPU cost out of the Node.js event loop. With Nginx, the relevant
directives are gzip on, gzip_types, gzip_min_length and gzip_comp_level. Compressing in Node.js is still the
right choice when there is no proxy in front, in development, or when the proxy is a plain TCP load balancer.
In Express with the compression middleware
The compression module is the official middleware. It negotiates with the Accept-Encoding request header, compresses
with gzip or deflate (recent releases also support Brotli, so check the module’s page for the version you install), sets
Content-Encoding and Vary: Accept-Encoding, and removes Content-Length because the final size is unknown. Mount it
before the routes whose responses it should compress:
// compression.mjs
import express from 'express';
import compression from 'compression';
const app = express();
app.use(
compression({
threshold: 1024, // do not compress bodies smaller than 1 kB (the default)
level: 6, // zlib level 1 (fastest) to 9 (smallest); -1 is the zlib default
filter(req, res) {
// Let clients opt out, for example while debugging with curl.
if (req.headers['x-no-compression']) return false;
return compression.filter(req, res); // default: compress compressible MIME types
},
}),
);
app.get('/api/v1/books', (req, res) => {
res.json(Array.from({ length: 500 }, (_, i) => ({ id: i + 1, title: `Book ${i + 1}` })));
});
app.listen(3000);
Source: Express middleware: compression
The options that matter in practice:
| Option | Effect |
|---|---|
|
Minimum body size in bytes before compression is attempted. Tiny bodies cost more CPU than they save bytes. |
|
zlib compression level. Higher levels cost much more CPU for small gains; 4 to 6 is the usual sweet spot for dynamic content. |
|
Function deciding per response. The default only compresses types recognised as compressible by the |
|
Passed to zlib. Rarely worth changing. |
Three pitfalls come up repeatedly. First, do not compress already-compressed formats (JPEG, PNG, WebP, MP4, ZIP);
the default filter skips them. Second, compressing responses that mix secrets and attacker-controlled input can enable
BREACH-style attacks; the usual mitigations are to avoid reflecting user input next to secrets or to disable compression
for those endpoints. Third, server-sent events need an explicit flush, because the compressor buffers; call
res.flush() after each event or exclude the route through filter. Static assets are better served pre-compressed
(.gz or .br files produced at build time) by the proxy or a CDN than compressed on every request; see
Serving Static Files.
Avoiding synchronous work
Node.js runs all JavaScript on one thread. A synchronous call that takes 50 ms stops every other request for 50 ms, so a server that handles 20 such requests per second is saturated, whatever its hardware. The official page puts it bluntly: do not use synchronous functions in production request paths. The usual offenders:
-
the
Syncvariants of file-system calls (readFileSync,writeFileSync,statSync,existsSync) and ofnode:child_process(execSync,spawnSync); -
CPU-heavy built-ins used synchronously:
crypto.pbkdf2Sync,crypto.scryptSync,zlib.gzipSync; -
JSON.parseandJSON.stringifyon very large documents, and regular expressions with catastrophic backtracking; -
template compilation and
requireorimportof modules inside handlers.
Startup code is the one legitimate place for synchronous calls: reading a configuration file or a template before
app.listen() runs happens once. To find the rest, start the process with --trace-sync-io, which prints a warning
and a stack trace each time a synchronous API is called after the first turn of the event loop:
node --trace-sync-io server.mjs
The flag is a diagnostic: it is noisy, so use it in staging, not in production. Pair it with the event-loop delay
histogram from node:perf_hooks, which gives the symptom (the loop is late) while the flag gives the cause:
// event-loop-lag.mjs
import { monitorEventLoopDelay } from 'node:perf_hooks';
const histogram = monitorEventLoopDelay({ resolution: 20 });
histogram.enable();
setInterval(() => {
const p99 = histogram.percentile(99) / 1e6; // nanoseconds to milliseconds
if (p99 > 100) console.warn(`event loop p99 delay ${p99.toFixed(1)} ms`);
histogram.reset();
}, 10_000).unref();
The fix for a blocking call is almost always its asynchronous counterpart (node:fs/promises, crypto.pbkdf2 with a
promise wrapper), and, for pure computation, a worker thread (see CPU-bound work and worker threads). Asynchronous password hashing runs on
the libuv thread pool, whose size defaults to four threads and can be changed with the UV_THREADPOOL_SIZE environment
variable; a burst of logins can exhaust it and delay file-system calls that share it.
Logging
Logging is a performance feature in both directions. The official advice is to use a logging library rather than
console.log in production, because console.log and console.error are synchronous when stdout is a file or a
terminal (and asynchronous only for pipes and sockets on POSIX systems), so heavy logging directly slows requests.
Two library families are recommended:
-
for debugging, the
debugmodule with namespaces switched by theDEBUGenvironment variable, which is how Express itself logs (see Debugging and Logging); -
for application logs, a fast structured logger such as
pino(orwinston), writing JSON to stdout so that the platform collects it.
pino is designed to keep logging off the hot path: it serialises minimal JSON and leaves rotation, shipping and
pretty-printing to other processes. pino-http adds a request logger with a per-request child logger and a request id:
// logging.mjs
import { randomUUID } from 'node:crypto';
import express from 'express';
import pino from 'pino';
import pinoHttp from 'pino-http';
const logger = pino({
level: process.env.LOG_LEVEL ?? 'info',
redact: ['req.headers.authorization', 'req.headers.cookie'], // never log credentials
});
const app = express();
app.use(
pinoHttp({
logger,
genReqId: (req) => req.headers['x-request-id'] ?? randomUUID(),
// Health probes would drown the log; skip them.
autoLogging: { ignore: (req) => req.url === '/healthz' },
}),
);
app.get('/api/v1/books/:id', (req, res) => {
req.log.info({ bookId: req.params.id }, 'loading book'); // child logger carries the request id
res.json({ id: req.params.id, title: 'Placeholder title' });
});
app.listen(3000, () => logger.info('Bookshelf API listening on port 3000'));
Keep the log level at info or above in production, never log request bodies by default, redact credentials, and write
to stdout instead of to files from inside the process: log rotation and shipping belong to the platform (systemd
journal, Docker, Kubernetes, a log agent). In development, pipe the output through pino-pretty rather than enabling
pretty printing in the application.
Handling exceptions
An uncaught exception in an Express application is not just a failed request: in Node.js it terminates the process and every in-flight request with it. The official page lists the tools for keeping that from happening and the one thing not to do.
-
Use
try/catchfor synchronous code andasyncfunctions for asynchronous code. In Express 5 a rejected promise returned by a handler or middleware is forwarded to the error handlers automatically; in Express 4 you must callnext(err)yourself or wrap the handler. Details are in Error Handling. -
Do not use
process.on('uncaughtException')to keep running. After an uncaught exception the process state is undefined, resources may be half-initialised, and the official page recommends letting the process die and be restarted by a supervisor (see Automatic restarts: process managers and init systems). A handler is acceptable only to log the error, flush logs and exit. -
Handle
unhandledRejectionthe same way: log, then exit with a non-zero code. Since Node.js 15 an unhandled rejection already terminates the process by default.
// last-resort.mjs
import pino from 'pino';
const logger = pino();
function fatal(error, origin) {
logger.fatal({ err: error, origin }, 'fatal error, exiting');
// pino writes synchronously to stdout by default, so the line is flushed before exit.
process.exit(1);
}
process.on('uncaughtException', (error, origin) => fatal(error, origin));
process.on('unhandledRejection', (reason) => fatal(reason, 'unhandledRejection'));
For a clean stop that finishes in-flight requests before exiting, see Health Checks and Graceful Shutdown.
CPU-bound work and worker threads
Anything that is computation rather than waiting (image resizing, PDF generation, large JSON transforms, hashing a big file, report aggregation) blocks the loop for as long as it runs, and making it asynchronous in appearance does not change that. Three options exist, in order of preference:
-
Move the work out of the request: enqueue a job and answer
202 Acceptedwith a status URL. A queue consumer in a separate process then does the work (see Background Jobs, Scheduling and Queues). -
Run it in a worker thread from
node:worker_threads, sharing the process but not the event loop. -
Run it in a child process or another service when isolation or a different runtime is needed.
Creating a worker per request is expensive (each worker is a new V8 isolate), so use a pool. piscina is the
widely used one: it keeps a fixed number of workers, queues tasks and returns promises.
// workers/summarise.mjs (runs inside a worker thread)
export default function summarise({ text }) {
// Deliberately CPU-heavy: word frequencies over a large text.
const counts = new Map();
for (const word of text.toLowerCase().match(/[\p{L}\p{N}]+/gu) ?? []) {
counts.set(word, (counts.get(word) ?? 0) + 1);
}
return [...counts].sort((a, b) => b[1] - a[1]).slice(0, 10);
}
// pool.mjs
import { availableParallelism } from 'node:os';
import express from 'express';
import Piscina from 'piscina';
const pool = new Piscina({
filename: new URL('./workers/summarise.mjs', import.meta.url).href,
maxThreads: Math.max(1, availableParallelism() - 1), // leave a core for the event loop
maxQueue: 100, // reject instead of queueing without bound
});
const app = express();
app.use(express.json({ limit: '1mb' }));
app.post('/api/v1/books/summary', async (req, res) => {
if (pool.queueSize >= 100) {
return res.status(503).set('Retry-After', '5').json({ error: 'Busy, retry later' });
}
const top = await pool.run({ text: String(req.body.text ?? '') }); // rejection reaches the error handler (Express 5)
res.json({ top });
});
app.listen(3000);
Workers pay a serialisation cost for the data they receive and return (structured clone, or zero-copy for transferred
ArrayBuffer objects), so they help when the computation is much more expensive than the copy. They do not help for
I/O-bound work, which the event loop already handles concurrently.
NODE_ENV and production mode
Set the environment variable NODE_ENV to production in every deployed environment. Express itself, and many
libraries, branch on it. The official page reports that doing so can make an application several times faster, and the
documented effects in Express are:
-
view caching is enabled: compiled templates are cached instead of being recompiled on each
res.render()(theview cachesetting defaults totruein production); -
error responses from the default handler omit the stack trace and show only the status message;
-
more CSS and file-system caching in supporting libraries, and less verbose behaviour in modules that check it (
pino-prettysetups, ORMs, template engines).
Set it outside the code (the unit file, the container, the PaaS dashboard, the PM2 ecosystem file), never with an assignment inside the application, which runs too late for modules already loaded:
NODE_ENV=production node server.mjs
Install with npm ci --omit=dev for production images: devDependencies add startup time, image size and attack
surface. NODE_ENV should only ever be development, test or production; inventing values such as staging makes
libraries silently fall back to their development behaviour. Configuration that differs between staging and production
belongs in separate variables (see
Project Structure and Configuration).
Automatic restarts: process managers and init systems
A crashed process must come back without a human. The official page names two layers.
-
A process manager supervises the application: restarts it on crash, can run several copies, collects logs and offers zero-downtime reloads. PM2 is the best-known Node.js option;
foreverandnodemonare older andnodemonis a development tool, not a production supervisor. -
An init system (systemd on most Linux distributions, or an orchestrator such as Kubernetes or ECS) starts the application at boot and restarts it. If you use an orchestrator or containers, it already is your process manager; do not run PM2 inside a container unless you need its cluster mode, because two supervisors give conflicting restart decisions.
A minimal systemd unit for the Bookshelf API:
# /etc/systemd/system/bookshelf.service
[Unit]
Description=Bookshelf API
After=network.target
[Service]
Type=simple
User=bookshelf
WorkingDirectory=/opt/bookshelf
Environment=NODE_ENV=production
Environment=PORT=3000
EnvironmentFile=/etc/bookshelf/bookshelf.env
ExecStart=/usr/bin/node server.mjs
Restart=always
RestartSec=2
# Give the application time to finish in-flight requests on SIGTERM.
TimeoutStopSec=30
[Install]
WantedBy=multi-user.target
The EnvironmentFile holds secrets and is readable only by root; the repository contains placeholders only. Pair
Restart=always with a crash-loop limit (StartLimitBurst) so a permanently broken release does not restart forever
without anyone noticing.
Running in a cluster
One Node.js process uses one core for JavaScript. On a four-core machine a single process leaves three cores idle. Clustering runs several identical processes on the same machine, all listening on the same port, so that connections spread across cores; it also keeps the service up while one worker restarts. Express 4 and 5 are unaffected: the technique is a property of the process model.
node:cluster
The built-in node:cluster module forks workers from a primary process; the operating system or the primary
distributes incoming connections (round-robin on Linux and macOS by default):
// cluster.mjs
import cluster from 'node:cluster';
import { availableParallelism } from 'node:os';
import process from 'node:process';
if (cluster.isPrimary) {
const workers = Number(process.env.WEB_CONCURRENCY ?? availableParallelism());
console.log(`primary ${process.pid} starting ${workers} workers`);
for (let i = 0; i < workers; i++) cluster.fork();
cluster.on('exit', (worker, code, signal) => {
console.warn(`worker ${worker.process.pid} died (${signal ?? code}), forking a replacement`);
cluster.fork();
});
} else {
const { default: express } = await import('express');
const app = express();
app.get('/api/v1/books', (req, res) => res.json({ worker: process.pid, books: [] }));
app.listen(3000);
}
Workers do not share memory. Anything kept in a JavaScript variable (an in-memory cache, a Map of sessions, a rate-limit
counter, a WebSocket room) exists once per worker, so state must move to Redis or a database. A restart loop without a
delay can also spin the CPU; add a back-off if workers die immediately.
PM2 cluster mode
PM2 wraps node:cluster so the application code needs no changes, and adds log handling and graceful reloads. The
official page shows the quick form pm2 start app.js -i max; a configuration file is better for repeatability:
// ecosystem.config.cjs
module.exports = {
apps: [
{
name: 'bookshelf-api',
script: './server.mjs',
exec_mode: 'cluster',
instances: 'max', // one worker per CPU core
max_memory_restart: '400M', // restart a worker that leaks past this size
kill_timeout: 10000, // ms between SIGINT and SIGKILL for graceful shutdown
env_production: {
NODE_ENV: 'production',
PORT: 3000,
},
},
],
};
pm2 start ecosystem.config.cjs --env production
pm2 reload bookshelf-api # zero-downtime restart, one worker at a time
pm2 save && pm2 startup # restore the process list at boot
The PM2 configuration file uses CommonJS (.cjs) so that it loads regardless of the type field in package.json.
In containers and Kubernetes the common alternative is one process per container and horizontal scaling by the
orchestrator, which gives the same parallelism with simpler failure handling; use node:cluster or PM2 when you run
on a bare virtual machine.
Caching
The fastest request is the one the application never computes. The official page recommends caching request results so that the application does not repeat the same work, and there are several layers, from the client inward:
| Layer | What it caches | Typical tool |
|---|---|---|
Browser and CDN |
Complete responses, driven by |
HTTP headers set by Express, a CDN |
Reverse proxy |
Complete responses shared by all users |
Nginx |
Application |
Results of expensive computations or queries, keyed by input |
In-process |
Database |
Query plans and hot pages |
Managed by the database |
HTTP-level caching, including the ETag that Express generates by default, conditional requests and
Cache-Control directives, is the subject of
Content Negotiation and HTTP Caching. For the
application layer, a small helper that caches an expensive read in Redis, with a time-to-live and an explicit
invalidation on writes, covers most needs:
// cache.mjs
import express from 'express';
import { createClient } from 'redis';
const redis = createClient({ url: process.env.REDIS_URL ?? 'redis://localhost:6379' });
redis.on('error', (error) => console.error('redis error', error.message));
await redis.connect();
async function cached(key, ttlSeconds, load) {
const hit = await redis.get(key);
if (hit !== null) return JSON.parse(hit);
const value = await load();
await redis.set(key, JSON.stringify(value), { EX: ttlSeconds });
return value;
}
const books = new Map([['1', { id: '1', title: 'Placeholder title' }]]); // stands in for the database
const app = express();
app.use(express.json());
app.get('/api/v1/books/:id', async (req, res) => {
const book = await cached(`book:${req.params.id}`, 60, async () => books.get(req.params.id) ?? null);
if (!book) return res.status(404).json({ error: 'Not found' });
res.set('Cache-Control', 'public, max-age=30');
res.json(book);
});
app.put('/api/v1/books/:id', async (req, res) => {
books.set(req.params.id, { id: req.params.id, ...req.body });
await redis.del(`book:${req.params.id}`); // invalidate on write
res.status(204).end();
});
app.listen(3000);
Four rules keep a cache from becoming a source of bugs. Key on everything the response depends on (path, query, and
for per-user data the user identity; never cache Set-Cookie or authenticated responses in a shared cache). Always set
a time-to-live, so a missed invalidation heals itself. Prevent stampedes on a popular key expiring (a short lock, or
serving stale data while one request refreshes). And treat the cache as optional: if Redis is down, fall back to the
database rather than failing the request.
At the proxy, a public read endpoint can be cached by Nginx without any application code, provided the application
sends correct Cache-Control headers; Varnish does the same with VCL rules. Because the proxy is not part of the
Express process, it also survives application restarts.
Load balancers, reverse proxies and sticky sessions
Beyond a single machine, a load balancer spreads traffic across several instances of the application and removes the ones that fail health checks. A reverse proxy (Nginx, Caddy, HAProxy, a cloud edge) sits in front of one or more instances and takes over work Node.js is poor at: TLS termination, compression, static file serving, request buffering for slow clients, rate limiting at the edge and caching. The official page recommends both, and the two are usually the same product.
Two consequences for the application:
-
Behind a proxy, Express must be told to trust it, or
req.ip,req.protocolandreq.securedescribe the proxy instead of the client. Configure thetrust proxysetting precisely, as explained in Behind Proxies. -
Instances must be stateless. Sessions, uploads, caches and rate-limit counters held in memory break as soon as two requests of one user reach different instances. Store them in Redis or a database. When that is not possible, sticky sessions (session affinity, by cookie or by client address) pin a user to one instance; they work but defeat even load distribution, complicate rolling deployments and lose the session when the instance dies. WebSocket libraries that use long-polling fallbacks, such as Socket.IO, need affinity or a shared adapter (see Real-time: WebSockets and SSE).
An Nginx configuration that combines proxying, compression and caching for the Bookshelf API:
# /etc/nginx/conf.d/bookshelf.conf
proxy_cache_path /var/cache/nginx/bookshelf levels=1:2 keys_zone=bookshelf:10m max_size=256m inactive=10m;
upstream bookshelf_api {
least_conn;
server 127.0.0.1:3000;
server 127.0.0.1:3001;
keepalive 32;
}
server {
listen 80;
server_name api.example.test;
gzip on;
gzip_types application/json text/css application/javascript image/svg+xml;
gzip_min_length 1024;
location /api/ {
proxy_pass http://bookshelf_api;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache bookshelf;
proxy_cache_valid 200 30s;
proxy_cache_methods GET HEAD;
add_header X-Cache-Status $upstream_cache_status;
}
}
The keepalive directive reuses upstream connections, which removes a TCP handshake from every proxied request.
Measuring with autocannon
autocannon is a fast HTTP/1.1 benchmarking tool written in Node.js, usable from the command line or as a library. A
typical run uses a number of connections, a duration, and optionally pipelining:
npx autocannon --connections 100 --duration 20 --pipelining 1 http://localhost:3000/api/v1/books
The report shows latency percentiles, requests per second, throughput and the count of non-2xx responses, which should be zero in a valid run. The same measurement can be scripted and compared before and after a change:
// bench.mjs
import autocannon from 'autocannon';
const result = await autocannon({
url: process.env.TARGET ?? 'http://localhost:3000/api/v1/books',
connections: 100,
duration: 20,
headers: { 'accept-encoding': 'gzip' },
});
console.log(`requests/s (avg): ${result.requests.average}`);
console.log(`latency p99: ${result.latency.p99} ms`);
console.log(`non-2xx responses: ${result.non2xx}`);
if (result.non2xx > 0) process.exitCode = 1;
Source: mcollina/autocannon
A disciplined experiment changes one thing at a time, repeats each run at least three times, warms the server before measuring, and records the Node.js version, Express version, core count and exact command. When a change does not move the numbers, revert it. For finding where the time goes once a regression is visible, use the profilers described in Profiling and Performance.
Production checklist
| Item | Check |
|---|---|
Environment |
|
Compression |
Done at the proxy, or |
Event loop |
No |
Logging |
Structured JSON to stdout with |
Failure |
Supervisor with |
Scale |
One process per core (PM2, |
Caching |
Correct |
Proxy |
|
Measurement |
An |
Where the books are outdated
The reference books were written for Express 3 and 4 on early Node.js releases. The performance advice they touch has changed in these ways:
| Older practice | Today |
|---|---|
Using |
Use systemd, a container orchestrator or PM2; |
Running a single Node.js process, or a hand-written cluster |
Use |
Logging with |
Use |
Compressing with the bundled |
Removed in Express 4; install the |
Keeping the process alive with an |
Let the process crash and restart it; log and exit in the handler. |
Offloading CPU work to a child process by default |
Worker threads (stable since Node.js 12) and pools such as |
Hosting on Heroku free dynos or similar PaaS tiers, with in-memory sessions |
Free tiers changed or disappeared; design instances to be stateless and keep sessions in Redis or a database. |
Blocking template rendering and synchronous file reads in handlers |
Use asynchronous APIs; Express 5 always renders asynchronously, and |