· frameworks  · 6 min read

Unlocking Performance: 10 Underused Express.js Middleware Tips

Explore 10 lesser-known Express.js middleware tips - from pre-compressed static assets and conditional GETs to streaming uploads and route-level caching - to boost throughput and reduce latency in production apps.

Explore 10 lesser-known Express.js middleware tips - from pre-compressed static assets and conditional GETs to streaming uploads and route-level caching - to boost throughput and reduce latency in production apps.

Why middleware matters for Express.js performance

Express middlewares are powerful - but when applied without thought they can become bottlenecks. The good news: small, targeted middleware changes often yield big perf wins without changing your app architecture.

Below are 10 underused (or under-tuned) middleware strategies you can apply right away. Each tip includes code examples, trade-offs, and links to useful libraries and docs.


Tip 1 - Mount heavy middleware only where needed (route-level / lazy middleware)

Problem: You apply heavy middleware (authentication, body parsing, large JSON parsing, validation) globally and every request pays the cost.

Solution: Apply heavy middleware to the specific routes that need it using routers or lazy-loading.

// Apply parser only to API routes that expect JSON
const apiRouter = express.Router();
apiRouter.use(express.json({ limit: '100kb' }));
apiRouter.post('/orders', validateOrder, createOrder);
app.use('/api', apiRouter);

// Lazy require for very heavy middleware
app.use('/admin', (req, res, next) => {
  const adminAuth = require('./heavy-admin-auth');
  return adminAuth(req, res, next);
});

Why: Reduces CPU / memory overhead on high-volume endpoints (static assets, health checks).

Trade-off: Ensure consistent behavior across routes-implicit differences can be confusing without clear docs.


Tip 2 - Serve pre-compressed static assets (Brotli/Gzip) instead of compressing on the fly

Problem: Compression is CPU-intensive. On-the-fly brotli/gzip for every static file can cost CPU and increase latency.

Solution: Pre-compress assets during your build (generate .br and .gz files) and serve those when the client accepts them. Use middleware like [express-static-gzip] or custom logic.

const expressStaticGzip = require('express-static-gzip');
app.use(
  '/',
  expressStaticGzip('public', {
    enableBrotli: true,
    orderPreference: ['br', 'gz'],
  })
);

Why: Offloads CPU work to CI/build (faster responses, lower server load).

References: express-static-gzip


Tip 3 - Configure compression smartly (thresholds & filters)

Problem: Blindly compressing tiny payloads or already-compressed formats wastes CPU.

Solution: Use the compression middleware with a size threshold and a custom filter for content types.

const compression = require('compression');
app.use(
  compression({
    threshold: 1024, // only compress responses > 1KB
    filter: (req, res) => {
      const type = res.getHeader('Content-Type') || '';
      // skip compressing images and pre-compressed formats
      if (/image|zip|compressed/.test(type)) return false;
      return compression.filter(req, res);
    },
  })
);

Why: Saves CPU cycles and avoids pointless compression.

Reference: compression


Tip 4 - Honor conditional GETs: etag and Last-Modified

Problem: Clients re-download unchanged resources and servers spend CPU and bandwidth recomputing responses.

Solution: Use ETags and Last-Modified headers so clients can perform conditional requests (If-None-Match / If-Modified-Since). Express has etag support built-in; tune it.

app.set('etag', 'strong'); // 'weak' is default in some versions

app.get('/api/report', (req, res) => {
  const data = renderReport();
  res.set('Last-Modified', getReportTimestamp());
  // Express will automatically handle If-None-Match when etag is set
  res.send(data);
});

Why: Cuts bandwidth and CPU for unmodified resources.

Reference: Express app.set(‘etag’)


Tip 5 - Use long Cache-Control + immutable for versioned assets

Problem: Clients repeatedly revalidate unchanged JavaScript/CSS blobs.

Solution: When assets are fingerprinted (e.g., app.abcd1234.js), serve them with Cache-Control: public, max-age=31536000, immutable.

app.use(
  express.static('public', {
    maxAge: '1y',
    setHeaders: (res, path) => {
      if (path.endsWith('.js') || path.endsWith('.css')) {
        res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
      }
    },
  })
);

Why: Avoids revalidation hops and speeds repeat page loads.


Tip 6 - Stream large uploads (busboy/multer streaming) instead of buffering

Problem: express.json() and other body parsers buffer the whole request - OOM risk for large uploads.

Solution: Use streaming parsers (e.g., [busboy] or multer with streaming) to stream file uploads straight to disk/cloud.

const Busboy = require('busboy');
app.post('/upload', (req, res) => {
  const busboy = new Busboy({ headers: req.headers });
  busboy.on('file', (fieldname, file, filename) => {
    const out = fs.createWriteStream(path.join('/uploads', filename));
    file.pipe(out);
  });
  busboy.on('finish', () => res.sendStatus(204));
  req.pipe(busboy);
});

Why: Reduces memory usage and improves concurrency.

References: busboy on npm, multer


Tip 7 - Add in-process route-level caches for expensive GETs (apicache or custom)

Problem: Recomputing expensive GET responses (database heavy or CPU-bound) on every request wastes resources.

Solution: Use a lightweight caching middleware (e.g., [apicache]) or implement TTL caches keyed by URL/params.

const apicache = require('apicache');
const cache = apicache.middleware;
app.get('/api/top', cache('5 minutes'), async (req, res) => {
  const results = await heavyQuery();
  res.json(results);
});

Why: Drastically reduces latency and database load for common queries.

Trade-off: Must handle cache invalidation carefully.

Reference: apicache


Tip 8 - Tune body parsing: limits, type whitelists, and webhooks

Problem: Unlimited body parsers let clients send enormous payloads that can block the event loop or exhaust memory.

Solution: Specify limits and selectively parse only the expected content types. Use raw parsing for webhooks to verify signatures.

// JSON body parser with tight limit
app.use('/api', express.json({ limit: '100kb' }));

// Raw body for webhooks that require exact bytes
app.post(
  '/webhook',
  express.raw({ type: 'application/json', limit: '50kb' }),
  (req, res) => {
    verifySignature(req.body); // raw buffer
    res.sendStatus(200);
  }
);

Why: Protects memory and avoids denial-of-service via large request bodies.

Reference: Express body parsing


Tip 9 - Make logging low-impact: skip assets and measure response times asynchronously

Problem: Heavy logging (including body dumps) for every request slows throughput.

Solution: Use morgan with a skip for static assets and capture timing using res.once('finish') so metric reporting is async.

const morgan = require('morgan');
app.use(
  morgan('combined', {
    skip: (req, res) =>
      req.path.startsWith('/assets/') || req.path === '/favicon.ico',
  })
);

// Lightweight response timing for metrics
app.use((req, res, next) => {
  const start = process.hrtime();
  res.once('finish', () => {
    const diff = process.hrtime(start);
    const ms = diff[0] * 1e3 + diff[1] * 1e-6;
    // push ms to your metrics backend asynchronously
    metricsClient.push({ route: req.path, latency: ms }).catch(() => {});
  });
  next();
});

Why: Keeps logs meaningful and metrics accurate without blocking request flow.

Reference: morgan


Tip 10 - Use robust rate-limiting and slow-down strategies with external stores

Problem: Naive in-memory rate limiting fails in multi-process or containerized environments and can cause spikes.

Solution: Use mature libraries (e.g., [rate-limiter-flexible] or [express-rate-limit] with Redis backing) to implement rate-limits and gradual slow-downs.

const { RateLimiterRedis } = require('rate-limiter-flexible');
const redisClient = require('./redis');
const rateLimiter = new RateLimiterRedis({
  storeClient: redisClient,
  points: 10,
  duration: 1,
});

app.use(async (req, res, next) => {
  try {
    await rateLimiter.consume(req.ip);
    next();
  } catch (rejRes) {
    res.set('Retry-After', String(Math.round(rejRes.msBeforeNext / 1000)));
    res.status(429).send('Too Many Requests');
  }
});

Why: Protects downstream systems and prevents abusive clients from monopolizing resources.

References: rate-limiter-flexible, express-rate-limit


Extra quick wins and checklist

  • Disable unnecessary headers: app.disable('x-powered-by') to reduce fingerprinting and tiny header overhead.
  • Set app.set('trust proxy', true) only when behind a reverse proxy-wrong settings can break rate-limiting and IP detection.
  • Use a CDN for global asset delivery; middleware should be optimized for origin only.
  • Consider a reverse proxy (Nginx) for connection handling, keep-alive tuning, and caching static content; let Express focus on app logic.

Final notes: measure and iterate

Make performance changes behind a flag and measure with real traffic or realistic load tests (autocannon, k6). Profiling and metrics will tell you which middleware adjustments actually move the needle for your workload.

Further reading

Apply these middleware tips incrementally, measure their impact, and you’ll often get far more throughput and lower latency for little development cost.

Back to Blog

Related Posts

View All Posts »