· 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.

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
- Express documentation: https://expressjs.com/
- compression: https://www.npmjs.com/package/compression
- express-static-gzip: https://www.npmjs.com/package/express-static-gzip
- busboy: https://www.npmjs.com/package/busboy
- apicache: https://www.npmjs.com/package/apicache
- rate-limiter-flexible: https://www.npmjs.com/package/rate-limiter-flexible
Apply these middleware tips incrementally, measure their impact, and you’ll often get far more throughput and lower latency for little development cost.