HTTP keep-alive and compression in Node
You've optimized your NestJS routes, tuned your database queries, and deployed to production—yet users still complain about occasional slowness. The culprit is often not your application logic, but how Node.js handles connection reuse and response size. HTTP keep-alive and compression are two settings that quietly affect latency, and getting them right can shave 50–200ms off response times without touching a line of business code.
In this article, I'll walk you through both mechanisms: how to reuse outbound connections efficiently, how to align your server's timeout with upstream infrastructure, and when to compress (and crucially, when not to). These are the kinds of details that separate production-ready systems from ones that work until they don't.
Understanding HTTP keep-alive: Why connection reuse matters
By default, HTTP/1.1 connections are kept alive. That means if your NestJS app makes an outbound request to an external API, the TCP connection stays open for a short window. If another request to the same host arrives within that window, Node reuses the connection instead of opening a new one.
Each new TCP connection costs time: a DNS lookup (if uncached), a three-way handshake, and TLS negotiation (for HTTPS). On a fast network, that's 10–50ms. On a slower or distant network, it can be 100ms or more. In a high-traffic system making thousands of external calls per second, repeated connection overhead adds up quickly.
The HTTP client in Node.js has a connection pool managed by an Agent. By default, it creates new sockets as needed and closes them after they're idle for a timeout period. For most applications, you'll want to explicitly configure this to control pool size and timeout behavior.
Here's a practical example: imagine your NestJS app calls a payment processor API. With keep-alive disabled, every payment request opens and closes a connection. With it enabled, you reuse the same connection for multiple payments, cutting latency by 20–30ms per request.
import http from 'http';
import https from 'https';
import axios from 'axios';
// Create agents with keep-alive enabled and a reasonable pool size
const httpAgent = new http.Agent({
keepAlive: true,
keepAliveMsecs: 1000, // Send TCP keep-alive probe every 1s
maxSockets: 50, // Max concurrent connections per host
maxFreeSockets: 10, // Max idle connections to keep in pool
timeout: 60000, // Socket timeout
});
const httpsAgent = new https.Agent({
keepAlive: true,
keepAliveMsecs: 1000,
maxSockets: 50,
maxFreeSockets: 10,
timeout: 60000,
});
// Use these agents in all outbound requests
const httpClient = axios.create({
httpAgent,
httpsAgent,
timeout: 30000,
});
export default httpClient;
This setup tells Node to reuse connections, keep them alive even when idle, and maintain a pool of up to 50 active connections per host. The maxFreeSockets limit prevents the pool from growing indefinitely.
Align server keepAliveTimeout with your load balancer
Now consider the flip side: your NestJS server receiving requests from clients. The server also has a keep-alive timeout—the duration it will hold a client connection open after the response is sent, waiting for the next request.
Here's where it gets tricky. If your load balancer (like AWS ALB or Nginx) has a shorter keep-alive timeout than your Node server, the load balancer will close the connection before Node knows about it. The client then sends a new request on the same connection, but the server has already closed its end. This causes a connection reset, which feels like a random, transient error.
The rule: your server's keepAliveTimeout should be slightly longer than your load balancer's timeout. This ensures the load balancer closes first, cleanly, and the server doesn't waste resources on connections the LB has already abandoned.
In NestJS, set the timeout on the underlying HTTP server:
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// Get the underlying HTTP server
const server = app.getHttpServer();
// Set keepAliveTimeout slightly higher than your LB
// If LB timeout is 65s, set this to 70s
server.keepAliveTimeout = 70 * 1000; // 70 seconds
// Optionally, set headersTimeout to handle slow clients
// This should be higher than keepAliveTimeout
server.headersTimeout = 75 * 1000; // 75 seconds
await app.listen(3000);
}
bootstrap();
In a typical AWS ALB setup, the default idle timeout is 60 seconds. Set your Node server to 70 seconds. If you use Nginx as a reverse proxy, check its keepalive_timeout directive (default is 75 seconds) and set Node accordingly.
Compress at Nginx or the CDN when possible
Response compression is another quiet win for latency. A 100KB JSON response compressed with gzip often becomes 10–15KB. Over a 10Mbps connection, that's the difference between 80ms and 8ms to transmit.
However, compression has a cost: CPU time on the server. Compressing every response in Node.js directly consumes valuable event loop cycles. If you have a reverse proxy or CDN in front of your app, they're often better suited for this work. Nginx and modern CDNs like Cloudflare can compress efficiently with hardware acceleration and without blocking your Node threads.
The best practice: compress at the edge, not in your Node process. Let Nginx or your CDN handle it.
If you must compress in Node (rare, but it happens), use the compression middleware and configure it carefully:
import compression from 'compression';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// Enable compression only for responses > 1KB and specific types
app.use(
compression({
filter: (req, res) => {
// Skip compression for small responses
if (req.headers['x-no-compression']) {
return false;
}
// Use compression's default filter
return compression.filter(req, res);
},
level: 6, // 1-9; higher = slower but smaller
threshold: 1024, // Only compress responses > 1KB
}),
);
await app.listen(3000);
}
bootstrap();
In Nginx, enable gzip with sensible defaults:
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types text/plain text/css text/xml text/javascript application/json application/javascript application/xml+rss;
gzip_min_length 1024;
gzip_disable "msie6";
The gzip_min_length directive prevents compression of tiny responses (where the overhead outweighs the benefit), and gzip_types ensures you only compress compressible content.
Avoid compressing tiny responses
This deserves its own section because it's a common mistake. Compressing a 300-byte JSON response adds overhead—the compression algorithm itself takes time and produces headers—without meaningful benefit.
A 300-byte response is already tiny and transmits almost instantly. Adding 200 bytes of gzip overhead (and the CPU cost of compression) actually makes it slower. The breakeven point depends on network conditions, but empirically, anything under 1KB usually shouldn't be compressed.
Both the compression middleware and Nginx allow you to set a threshold or gzip_min_length. Use it:
- Set
thresholdto at least 1024 bytes (1KB) in compression middleware. - Set
gzip_min_lengthto 1024 or higher in Nginx. - Monitor response sizes in production. If most responses are under 2KB, raise the threshold.
Also consider the content type. Compressing already-compressed formats (JPEG, MP4, PNG) is wasteful. Gzip shines on text, JSON, and XML. Configure your compression to exclude binary formats.
Putting it together: A real-world example
Let's say you're running a SaaS platform with a NestJS API behind Nginx, making calls to a Stripe-like payment API. Here's how you'd configure both sides:
1. Outbound requests (to payment API): Set up an HTTP client with keep-alive agents.
2. Inbound server timeout: If Nginx has a 60s timeout, set Node's keepAliveTimeout to 70s.
3. Compression: Delegate to Nginx with a 1KB threshold.
Result: Client requests reuse connections (saving 20–30ms), your server doesn't waste cycles compressing tiny payloads, and the connection lifecycle is clean and predictable.
Common mistakes
- Not configuring outbound agents: Leaving axios, fetch, or other HTTP clients without explicit agents means connections aren't pooled, and you burn through file descriptors. Always configure agents for outbound requests.
- Misaligned timeouts: Setting Node's keepAliveTimeout lower than the load balancer's timeout causes random "connection reset" errors that are hard to debug. Check your load balancer's timeout and add 5–10 seconds to Node's setting.
- Compressing everything: Using compression middleware without a threshold compresses tiny responses, wasting CPU. Set a minimum response size.
- Compressing at both edges: If Nginx already compresses, don't add the compression middleware in Node. Doing both wastes CPU and can cause double-compression issues.
- Ignoring content types: Compressing JPEG or already-compressed responses adds zero benefit and consumes CPU. Use the
typesfilter to exclude them.
Key takeaways
- HTTP keep-alive allows connection reuse; configure explicit agents in Node.js to pool outbound connections and avoid opening a new TCP connection for every request.
- Set your server's
keepAliveTimeout5–10 seconds higher than your load balancer's timeout to ensure clean connection closure and avoid spurious resets. - Compress at the reverse proxy or CDN layer (Nginx, Cloudflare) rather than in Node.js to offload CPU work from your application.
- Never compress responses smaller than 1KB; the overhead outweighs the benefit and wastes CPU cycles.
- Monitor your system in production. Response times, connection counts, and CPU usage reveal whether these settings are tuned correctly.






Comments
Loading comments…