Node.js HTTP Proxy TTFB Timeout (Keep-Alive Safe)
Problem
When implementing HTTP proxy/reverse-proxy code with TTFB (time-to-first-byte) timeouts,
socket-based timeout logic fails on keep-alive connections. The first request works fine,
but subsequent requests on reused sockets hang forever even though the upstream is dead.
Context / Trigger Conditions
- First HTTP request through proxy succeeds
- Second request (on same keep-alive connection) hangs indefinitely
- Upstream is a tunnel (cloudflared, ngrok) or service that can go offline
- Using
socket.setTimeout() or proxyReq.on('socket', ...) for timeout logic
- Timeout callback never fires for reused connections
- Half-open TCP connections (remote closed, local doesn't know)
Root Cause
Socket-based timeouts have race conditions with keep-alive connections:
- Fresh connection:
socket.connecting === true, timer starts on 'connect' event ✓
- Reused connection:
socket.connecting === false, timer starts immediately BUT
the response callback may already be pending, causing a race
- Half-open sockets: TCP connection is open locally but remote closed silently.
Writes succeed but reads hang forever. Socket-level checks don't detect this.
Solution
Use request.setTimeout() instead of socket-based timers. This works at the HTTP layer
and fires reliably for both fresh and reused connections.
Pattern: Two-Phase Timeout
const TTFB_TIMEOUT = 8_000; // 8s to get response headers
const BODY_TIMEOUT = 45_000; // 45s for streaming body
const proxyReq = https.request(targetUrl, options, (proxyRes) => {
// SUCCESS - extend timeout for body streaming
proxyReq.setTimeout(BODY_TIMEOUT);
// Handle response...
proxyRes.pipe(res);
});
proxyReq.on('error', (err) => {
// Handle connection/timeout errors
console.error('Request failed:', err.message);
});
// TTFB timeout: abort if no response headers within 8s
// Works reliably for both fresh and keep-alive connections
proxyReq.setTimeout(TTFB_TIMEOUT, () => {
console.error('TTFB timeout - upstream unreachable');
proxyReq.destroy(new Error('TTFB timeout'));
});
proxyReq.write(body);
proxyReq.end();
What NOT to Do (Anti-Pattern)
// DON'T: Socket-based timeout - fails on keep-alive connections
proxyReq.on('socket', (socket) => {
if (socket.connecting) {
socket.setTimeout(CONNECT_TIMEOUT, () => { /* ... */ });
socket.once('connect', () => {
// Start TTFB timer after connect
const ttfbTimer = setTimeout(() => { /* ... */ }, TTFB_TIMEOUT);
});
} else {
// Reused socket - this branch races with response callback!
const ttfbTimer = setTimeout(() => { /* ... */ }, TTFB_TIMEOUT);
}
});
Verification
- Start upstream service, send request → should succeed
- Send second request immediately → should succeed (keep-alive reuse)
- Kill upstream, send request → should timeout in TTFB_TIMEOUT, not hang forever
- Restart upstream, send request → should succeed
Example: Complete Proxy Implementation
function tryProvider(targetUrl, body) {
return new Promise((resolve, reject) => {
let settled = false;
const proxyReq = https.request(targetUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
}, (proxyRes) => {
if (settled) return;
// Extend timeout for body streaming
proxyReq.setTimeout(45_000);
settled = true;
resolve(proxyRes);
});
proxyReq.on('error', (err) => {
if (settled) return;
settled = true;
reject(err);
});
// TTFB timeout - catches "CDN up, origin dead" scenarios
proxyReq.setTimeout(8_000, () => {
if (!settled) {
proxyReq.destroy(new Error('TTFB timeout'));
}
});
proxyReq.write(body);
proxyReq.end();
});
}
Notes
request.setTimeout() resets the timer on each data event, making it suitable for
both TTFB detection and stalled-body detection
- The
settled guard prevents double-resolution in timeout vs response races
- For circuit breaker patterns, combine with health probes that pre-emptively open
the circuit when the upstream is detected as down
- Keep-alive connections are the default in Node.js (via
http.Agent). The issue
manifests when the upstream dies while connections are pooled.
Related Patterns
- Health Probe: Run periodic checks against upstream to detect failures early
- Circuit Breaker: After N failures, skip the dead upstream entirely
- Connection Pool Tuning: Reduce
agent.keepAlive timeout to match upstream
References
1---2name: node-http-proxy-ttfb-timeout3description: Fix HTTP proxy requests hanging forever on keep-alive connections in Node.js. Use when: (1) First request works but second request hangs indefinitely, (2) socket.setTimeout or manual timer on 'socket' event doesn't fire for reused connections, (3) Proxy sits in front of a tunnel/origin that may go down (e.g., laptop sleeping), (4) Need reliable TTFB (time-to-first-byte) timeout that works for both fresh and keep-alive connections. Applies to http.request, https.request, and reverse proxy implementations.4---56# Node.js HTTP Proxy TTFB Timeout (Keep-Alive Safe)78## Problem910When implementing HTTP proxy/reverse-proxy code with TTFB (time-to-first-byte) timeouts,11socket-based timeout logic fails on keep-alive connections. The first request works fine,12but subsequent requests on reused sockets hang forever even though the upstream is dead.1314## Context / Trigger Conditions1516- First HTTP request through proxy succeeds17- Second request (on same keep-alive connection) hangs indefinitely18- Upstream is a tunnel (cloudflared, ngrok) or service that can go offline19- Using `socket.setTimeout()` or `proxyReq.on('socket', ...)` for timeout logic20- Timeout callback never fires for reused connections21- Half-open TCP connections (remote closed, local doesn't know)2223## Root Cause2425Socket-based timeouts have race conditions with keep-alive connections:26271. **Fresh connection**: `socket.connecting === true`, timer starts on 'connect' event ✓282. **Reused connection**: `socket.connecting === false`, timer starts immediately BUT29 the response callback may already be pending, causing a race303. **Half-open sockets**: TCP connection is open locally but remote closed silently.31 Writes succeed but reads hang forever. Socket-level checks don't detect this.3233## Solution3435Use `request.setTimeout()` instead of socket-based timers. This works at the HTTP layer36and fires reliably for both fresh and reused connections.3738### Pattern: Two-Phase Timeout3940```javascript41const TTFB_TIMEOUT = 8_000; // 8s to get response headers42const BODY_TIMEOUT = 45_000; // 45s for streaming body4344const proxyReq = https.request(targetUrl, options, (proxyRes) => {45 // SUCCESS - extend timeout for body streaming46 proxyReq.setTimeout(BODY_TIMEOUT);4748 // Handle response...49 proxyRes.pipe(res);50});5152proxyReq.on('error', (err) => {53 // Handle connection/timeout errors54 console.error('Request failed:', err.message);55});5657// TTFB timeout: abort if no response headers within 8s58// Works reliably for both fresh and keep-alive connections59proxyReq.setTimeout(TTFB_TIMEOUT, () => {60 console.error('TTFB timeout - upstream unreachable');61 proxyReq.destroy(new Error('TTFB timeout'));62});6364proxyReq.write(body);65proxyReq.end();66```6768### What NOT to Do (Anti-Pattern)6970```javascript71// DON'T: Socket-based timeout - fails on keep-alive connections72proxyReq.on('socket', (socket) => {73 if (socket.connecting) {74 socket.setTimeout(CONNECT_TIMEOUT, () => { /* ... */ });75 socket.once('connect', () => {76 // Start TTFB timer after connect77 const ttfbTimer = setTimeout(() => { /* ... */ }, TTFB_TIMEOUT);78 });79 } else {80 // Reused socket - this branch races with response callback!81 const ttfbTimer = setTimeout(() => { /* ... */ }, TTFB_TIMEOUT);82 }83});84```8586## Verification87881. Start upstream service, send request → should succeed892. Send second request immediately → should succeed (keep-alive reuse)903. Kill upstream, send request → should timeout in TTFB_TIMEOUT, not hang forever914. Restart upstream, send request → should succeed9293## Example: Complete Proxy Implementation9495```javascript96function tryProvider(targetUrl, body) {97 return new Promise((resolve, reject) => {98 let settled = false;99100 const proxyReq = https.request(targetUrl, {101 method: 'POST',102 headers: { 'Content-Type': 'application/json' },103 }, (proxyRes) => {104 if (settled) return;105106 // Extend timeout for body streaming107 proxyReq.setTimeout(45_000);108 settled = true;109 resolve(proxyRes);110 });111112 proxyReq.on('error', (err) => {113 if (settled) return;114 settled = true;115 reject(err);116 });117118 // TTFB timeout - catches "CDN up, origin dead" scenarios119 proxyReq.setTimeout(8_000, () => {120 if (!settled) {121 proxyReq.destroy(new Error('TTFB timeout'));122 }123 });124125 proxyReq.write(body);126 proxyReq.end();127 });128}129```130131## Notes132133- `request.setTimeout()` resets the timer on each data event, making it suitable for134 both TTFB detection and stalled-body detection135- The `settled` guard prevents double-resolution in timeout vs response races136- For circuit breaker patterns, combine with health probes that pre-emptively open137 the circuit when the upstream is detected as down138- Keep-alive connections are the default in Node.js (via `http.Agent`). The issue139 manifests when the upstream dies while connections are pooled.140141## Related Patterns142143- **Health Probe**: Run periodic checks against upstream to detect failures early144- **Circuit Breaker**: After N failures, skip the dead upstream entirely145- **Connection Pool Tuning**: Reduce `agent.keepAlive` timeout to match upstream146147## References148149- [Node.js HTTP Documentation](https://nodejs.org/api/http.html)150- [A Complete Guide to Timeouts in Node.js | Better Stack](https://betterstack.com/community/guides/scaling-nodejs/nodejs-timeouts/)151- [How to Use Timeouts in Node.js | AppSignal Blog](https://blog.appsignal.com/2023/11/08/how-to-use-timeouts-in-nodejs.html)152- [Tuning HTTP Keep-Alive in Node.js](https://connectreport.com/blog/tuning-http-keep-alive-in-node-js/)