Performance Best Practices
Most Express performance problems come down to a handful of recurring mistakes: blocking the event loop, shipping uncompressed payloads, recomputing the same response, and leaving the framework in development mode in production. This page is a practical checklist you can run through before a service goes live. Each item is cheap to apply, measurable in its impact, and battle-tested across real production deployments — start at the top and work down.
Set NODE_ENV to production
This is the single highest-leverage flag in the entire stack, and it is free. When NODE_ENV=production, Express caches view templates, skips generating verbose stack traces, and the wider ecosystem (including its dependencies) disables debug code paths. Forgetting it can cost you a large fraction of your throughput on view-heavy apps.
NODE_ENV=production node server.js
You can assert it at boot so a misconfigured deploy fails loudly instead of silently running slow:
if (process.env.NODE_ENV !== 'production') {
console.warn('WARNING: NODE_ENV is not "production" — performance will suffer');
}
Output:
WARNING: NODE_ENV is not "production" — performance will suffer
Tip: Do not set
NODE_ENV=productionin your local.envchecked into git. Set it in the deploy environment (Docker, systemd, your platform’s dashboard) so each environment gets the right value.
Compress text responses with gzip
Express sends responses uncompressed by default. For JSON, HTML, and CSS, gzip or Brotli typically shrinks the body by 60-80%, slashing transfer time and bandwidth for a small CPU cost. Add the compression middleware early in the chain so it wraps every route.
const express = require('express');
const compression = require('compression');
const app = express();
// gzip responses larger than 1 KB (the default threshold)
app.use(compression({ threshold: 1024 }));
If a reverse proxy in front of Express (nginx, a CDN, or a load balancer) already compresses responses, do it there instead and skip the middleware — compressing twice wastes CPU.
Make everything async
Node runs your code on a single thread. Any synchronous CPU-heavy or blocking call freezes every concurrent request until it returns. Use the async, non-blocking variants of every API: fs.promises over fs.readFileSync, the async crypto functions, and awaited database calls.
const fs = require('fs/promises');
// BAD — blocks the event loop for every other request
app.get('/report-bad', (req, res) => {
const data = require('fs').readFileSync('./report.json', 'utf8');
res.type('json').send(data);
});
// GOOD — yields the loop while the file is read
app.get('/report', async (req, res, next) => {
try {
const data = await fs.readFile('./report.json', 'utf8');
res.type('json').send(data);
} catch (err) {
next(err);
}
});
Warning: In Express 5, rejected promises from
asynchandlers are forwarded to your error middleware automatically. In Express 4 you musttry/catchand callnext(err)yourself (as above) or wrap handlers — an unhandled rejection there silently hangs the request.
Cache stable responses
The fastest query is the one you never run. If a response depends only on inputs that change rarely, cache it — in process memory for small hot datasets, or in a shared store like Redis once you run more than one instance. For client-side caching, set explicit Cache-Control headers so browsers and CDNs can reuse responses without hitting your server at all.
const cache = new Map();
const TTL = 60_000;
app.get('/catalog', async (req, res, next) => {
const hit = cache.get('catalog');
if (hit && hit.expires > Date.now()) {
res.set('Cache-Control', 'public, max-age=60');
return res.json(hit.value); // served without touching the DB
}
try {
const value = await loadCatalogFromDb();
cache.set('catalog', { value, expires: Date.now() + TTL });
res.set('Cache-Control', 'public, max-age=60');
res.json(value);
} catch (err) {
next(err);
}
});
Pool your database connections
Opening a fresh connection per request is expensive and exhausts the database’s connection limit under load. Create one pool at startup and share it across all routes, sizing max to what your database can sustain.
const { Pool } = require('pg');
const pool = new Pool({ max: 10 }); // reuse up to 10 connections
app.get('/orders/:id', async (req, res, next) => {
try {
const { rows } = await pool.query(
'SELECT id, total, status FROM orders WHERE id = $1',
[req.params.id]
);
if (!rows.length) return res.sendStatus(404);
res.json(rows[0]);
} catch (err) {
next(err);
}
});
Offload work to a reverse proxy
Express is excellent at routing application logic and poor at tasks a dedicated proxy does better. Put nginx, a CDN, or your platform’s load balancer in front of Node and let it handle TLS termination, static-file serving, gzip, and rate limiting. This frees the event loop to do nothing but run your application code.
When behind a proxy, tell Express to trust it so req.ip and req.protocol reflect the original client:
app.set('trust proxy', 1); // trust the first proxy hop
Checklist summary
| Item | Why it matters | How |
|---|---|---|
NODE_ENV=production | Enables view caching, skips dev overhead | Set in the deploy environment |
| Compression | Shrinks text payloads 60-80% | compression middleware or proxy |
| Async everywhere | Keeps the single thread unblocked | fs/promises, async crypto, await |
| Caching | Avoids repeated DB and compute work | In-memory / Redis + Cache-Control |
| Connection pooling | Reuses costly DB connections | One shared pool at startup |
| Reverse proxy | Offloads TLS, static, gzip, rate limit | nginx / CDN / load balancer |
Best Practices
- Always run production with
NODE_ENV=productionand assert it at boot so misconfigured deploys fail loudly. - Enable response compression for text payloads — gzip at the proxy if you have one, otherwise via middleware, but never both.
- Use non-blocking async APIs for every I/O and CPU-heavy operation; nothing synchronous belongs on the request path.
- Cache anything stable with an explicit TTL, and add
Cache-Controlheaders so clients and CDNs share the load. - Reuse a single connection pool for your database instead of connecting per request.
- Offload TLS, static assets, and rate limiting to a reverse proxy, and set
trust proxyso client metadata stays accurate. - Load-test after each change — performance work is iterative, and the dominant cost shifts once you fix the first bottleneck.