Skip to content
Express.js ex performance 5 min read

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=production in your local .env checked 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 async handlers are forwarded to your error middleware automatically. In Express 4 you must try/catch and call next(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

ItemWhy it mattersHow
NODE_ENV=productionEnables view caching, skips dev overheadSet in the deploy environment
CompressionShrinks text payloads 60-80%compression middleware or proxy
Async everywhereKeeps the single thread unblockedfs/promises, async crypto, await
CachingAvoids repeated DB and compute workIn-memory / Redis + Cache-Control
Connection poolingReuses costly DB connectionsOne shared pool at startup
Reverse proxyOffloads TLS, static, gzip, rate limitnginx / CDN / load balancer

Best Practices

  • Always run production with NODE_ENV=production and 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-Control headers 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 proxy so client metadata stays accurate.
  • Load-test after each change — performance work is iterative, and the dominant cost shifts once you fix the first bottleneck.
Last updated June 14, 2026
Was this helpful?