#482

Plain HTTP mode (--no-tls) forwards the backend's `Connection: close`, so clients never reuse connections

OpenMichengLiangopened 26d ago

Summary

In plain HTTP mode the proxy copies the backend response headers verbatim, including Connection: close. Every client request therefore opens a new TCP connection. HTTPS/HTTP2 mode is not affected because it strips hop-by-hop headers.

Reproduction (portless 0.15.8, unprivileged port, isolated state dir)

export PORTLESS_STATE_DIR=$(mktemp -d)
portless proxy start --no-tls -p 1355
portless app node -e "require('node:http').createServer((q,s)=>s.end('ok\n')).listen(process.env.PORT,'127.0.0.1')" &

# direct to the backend: the connection is reused
curl -sv http://127.0.0.1:<app-port>/ http://127.0.0.1:<app-port>/ 2>&1 | grep -iE '^< connection|re-?using|closing'
# < Connection: keep-alive
# * Re-using existing connection with host 127.0.0.1
# < Connection: keep-alive

# through the proxy: closed after every response
curl -sv http://app.localhost:1355/ http://app.localhost:1355/ 2>&1 | grep -iE '^< connection|re-?using|closing'
# < connection: close
# * Closing connection
# < connection: close
# * Closing connection

Cause

https://github.com/vercel-labs/portless/blob/71fd45fd29004845cf665894c9c0bd2b887d7485/packages/portless/src/proxy.ts#L392-L398

const responseHeaders: http.OutgoingHttpHeaders = { ...proxyRes.headers };
if (reqTls) {
  for (const h of HOP_BY_HOP_HEADERS) {
    delete responseHeaders[h];
  }
}

Since the fix for #434 the proxy drops the client's Connection header and dials a fresh backend socket per request through createConnection, so Node sends Connection: close upstream and the backend answers Connection: close. That header is per-hop (RFC 9110 §7.6.1), but on the plain HTTP path it is forwarded to the client.

Impact

Normally one extra loopback handshake per request is cheap. It becomes expensive when establishing a connection is slow. On WSL2 with networkingMode=mirrored, a Windows browser tries ::1 first for *.localhost, which is silently dropped, and falls back to 127.0.0.1 after the ~200 ms Happy Eyeballs delay. With connection: close that delay is paid on every request. A Vite dev page with ~70 modules, measured in Edge on Windows with the app running in WSL:

RoutePage load (h1 visible)
Direct to Vite1.0–1.3 s
portless --no-tls, 0.15.84.0–5.2 s
portless --no-tls, patched as below1.3–1.4 s
portless HTTPS (h2, default)0.8–1.2 s

Suggested fix

Strip the per-hop response headers on the plain HTTP path too, and let Node's ServerResponse apply the proxy's own keep-alive policy to the client connection. transfer-encoding stays, as on the request side:

 if (reqTls) {
   for (const h of HOP_BY_HOP_HEADERS) {
     delete responseHeaders[h];
   }
+} else {
+  for (const h of REQUEST_HOP_BY_HOP_HEADERS) {
+    delete responseHeaders[h];
+  }
 }

I applied this to the built dist locally. Responses then carry Connection: keep-alive, curl reuses the connection, and the page load in the table drops back to 1.3–1.4 s. WebSocket upgrades go through the separate handleUpgrade path, which this change does not touch.

Environment

  • portless 0.15.8 (the same code is on main at 71fd45fd2900)
  • Node.js 24.14.1
  • WSL 2.7.14, Ubuntu 24.04, networkingMode=mirrored
  • Microsoft Edge 155 on Windows 11

Activity

0

No activity yet.