Proxies, load balancers and connection management
Forwarded and X-Forwarded headers, keep-alive and connection pooling, timeouts and retries, idempotency keys, and how CDNs and reverse proxies rewrite traffic.
What an intermediary changes
Once a request passes through anything — CDN, load balancer, service mesh, corporate proxy — the connection the server sees is no longer the connection the client made. The client IP, the scheme and sometimes the host only survive as headers.
GET /api/me HTTP/1.1
Host: app.example.com
Forwarded: for=203.0.113.7;proto=https;host=app.example.com
X-Forwarded-For: 203.0.113.7, 10.0.0.5
X-Forwarded-Proto: https
X-Forwarded-Host: app.example.com
X-Request-Id: 01J8Y7Q2ZK3M4N5P6R7S8T9V0W| Header | Meaning | Trust rule |
|---|---|---|
Forwarded | Standard: for, proto, host | Preferred; can carry a chain |
X-Forwarded-For | Client IP, then each proxy appended | The left-most value is client-controlled unless your edge overwrites it |
X-Forwarded-Proto | Original scheme | Needed to build absolute URLs and to know whether to set HSTS |
X-Request-Id | Correlation id added by the edge | Trust it only from your own edge; generate one if absent |
X-Real-IP | Single client address (nginx convention) | Convenience only |
- A reverse proxy terminates TLS, so the application sees plain HTTP on a private network. Rate limiting and audit logs must use the forwarded client address, not the proxy's.
- Strip any of these headers arriving from the public internet before adding your own, or a client can forge its own IP and defeat rate limiting.
- A load balancer with sticky sessions pins a client to one backend; that is a workaround for stateful servers, not a design.
Keep-alive, pooling and timeouts
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 82
Connection: keep-alive
Keep-Alive: timeout=5, max=100| Timeout | What it bounds | Symptom when missing |
|---|---|---|
| Connect | Reaching a peer at all | Workers pile up on an unreachable host |
| Read / socket | Waiting for bytes once connected | A hung upstream holds a connection forever |
| Total / request | The whole call including retries | One slow dependency stalls the request path |
| Pool idle | How long a spare connection is kept | Connections to a scaled-down backend go stale |
// a sized pool with timeouts and one retry for idempotent calls only
const agent = new http.Agent({
keepAlive: true,
maxSockets: 64,
maxFreeSockets: 16,
timeout: 10_000
});
async function get(path, { retries = 1 } = {}) {
for (let attempt = 0; ; attempt++) {
try {
const res = await fetch(BASE + path, { agent, signal: AbortSignal.timeout(8_000) });
if (res.status >= 500 && attempt < retries) continue;
return res;
} catch (err) {
if (attempt >= retries) throw err;
await new Promise(r => setTimeout(r, 100 * 2 ** attempt + Math.random() * 100));
}
}
}- HTTP/1.1 allows one outstanding request per connection, so concurrency equals pool size; HTTP/2 multiplexes streams over one connection and needs a much smaller pool.
- Reuse matters: a new TLS handshake per request can dominate the latency of an internal call. Keep-alive plus pooling is usually the single biggest win.
- Retry only idempotent requests by default, cap the attempts, and add jitter so a recovering service is not hit by every client at the same instant.
- Always honour
Retry-Afterfrom a429or503instead of applying your own backoff.
What CDNs and proxies rewrite
| Proxy type | Sits | Typically changes |
|---|---|---|
| Forward proxy | Client side | Sends absolute-form targets; may inject auth and block destinations |
| Reverse proxy | Server side | Terminates TLS, routes by host or path, adds forwarded headers |
| CDN edge | Between both | Caches, compresses, rewrites URLs, may buffer or split requests |
| Service mesh sidecar | Beside each service | Mutual TLS, retries, timeouts, circuit breaking |
location /api/ {
proxy_pass http://app_upstream;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Request-Id $request_id;
proxy_connect_timeout 3s;
proxy_send_timeout 30s;
proxy_read_timeout 30s;
proxy_next_upstream error timeout http_502 http_503;
}- A CDN may cache a response you expected to be dynamic. If the origin sends no cache directives, the edge applies its own defaults — be explicit.
- Some proxies buffer request bodies before forwarding, which breaks streaming uploads and disables early rejection of oversized payloads.
- Proxies may normalise paths and decode percent-escapes, so a route that depends on exact encoding can behave differently behind one.
- If you use
proxy_next_upstream, only idempotent methods are safe to replay by default; a POST retried at the proxy layer can create two orders.
⚠️
Retrying a non-idempotent request is how duplicates are made. When a client must retry a POST, give the server an idempotency key and let it return the original result for a repeated key — do not rely on luck or on the retry being rare.
FAQ
Why does my app see the load balancer's IP?
Because the peer address is the last hop, not the client. Read the forwarded client address but only after your edge has overwritten the header, and configure the framework's trusted-proxy list so it knows when to believe it.
How many connections should a pool hold?
Enough to cover concurrency divided by the requests per connection, and no more than the backend can serve. Oversized pools are a common cause of load balancer queueing and of reaching a database connection limit.
Related
Message anatomy and framing HTTP API design in practice
Last refreshed 2026-09-18.