Summary
setupMaster's workerId/connectionId are declared once per raw TCP connection (in the closure of httpServer.on("connection", (socket) => { let workerId, connectionId; ... })), not per HTTP request. Once the first request on a connection has a Content-Length or Transfer-Encoding header (mayHaveMultipleChunks), connectionId is set and never reset for the rest of that connection's lifetime — even after that request completes.
Any subsequent request sent over the same reused keep-alive connection then hits:
if (workerId && connectionId) {
cluster.workers[workerId].send({ type: "sticky:http-chunk", data, connectionId }, sendCallback);
return;
}
— treated as a continuation chunk of the first request, instead of being routed as a new sticky:connection. On the worker side, if the original socket has already been removed from the sockets map (e.g. because the first request's response already completed and the socket was closed), the later request's entire raw bytes — headers included, not just a body — are silently dropped (sockets.get(connectionId) returns undefined, nothing happens). The client hangs forever: no response, no error, no log output anywhere.
Every browser reuses HTTP/1.1 keep-alive connections by default, so this reproduces reliably against real browser traffic (Chrome) — a client sending a POST/PATCH/PUT with a body, followed by any other request on the same connection, is enough to trigger it. curl mostly avoids it in casual testing only because a fresh CLI invocation typically opens a fresh connection each time — it is not curl-specific, and reproduces the same way with a raw TCP connection carrying two pipelined requests.
Environment
@socket.io/sticky: 1.0.4 (latest on npm as of this report)
socket.io: 4.8.3
- Node.js: v24.18.0
- Confirmed via
@socket.io/pm2-orchestrated PM2 cluster mode (2 workers) and independently reproduced with vanilla Node cluster + @socket.io/sticky (no PM2 involved at all), ruling out PM2 as a factor.
Minimal reproduction
// repro.js
const cluster = require('cluster')
const http = require('http')
const { setupMaster, setupWorker } = require('@socket.io/sticky')
const { Server } = require('socket.io')
const PORT = 4001
if (cluster.isMaster) {
cluster.setupMaster({ windowsHide: true })
const httpServer = http.createServer()
setupMaster(httpServer, { loadBalancingMethod: 'least-connection' })
httpServer.listen(PORT, () => console.log(`listening on ${PORT}`))
cluster.fork()
cluster.fork()
} else {
const httpServer = http.createServer((req, res) => {
let body = ''
req.on('data', c => { body += c })
req.on('end', () => {
console.log(`[worker ${process.pid}] ${req.method} ${req.url} body=${JSON.stringify(body)}`)
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ ok: true, pid: process.pid, method: req.method }))
})
})
const io = new Server(httpServer)
setupWorker(io)
}
Steps:
node repro.js
- Send a
POST with a JSON body, then a second request (any method) reusing the same TCP connection — e.g. two curl requests piped down one raw socket, or via a real browser tab where Chrome's connection pool happens to reuse the socket. A real browser making a POST followed by any other request to the same origin is the easiest way to trigger this in practice — it reproduces reliably against actual application traffic, not just crafted pipelining.
- The second request hangs indefinitely if the first request's socket has already closed by the time the second request's bytes arrive as a
sticky:http-chunk — sockets.get(connectionId) returns undefined in setupWorker, and the request is silently dropped with no error anywhere.
Expected behavior
Each HTTP request on a keep-alive connection should be routed as its own logical unit — workerId/connectionId (or equivalent per-request state) should reset once a request/response cycle completes, so a second request on a reused connection is treated as a fresh request rather than a phantom continuation of the first.
Actual behavior
The second (and any later) request on a connection where the first request carried a Content-Length/Transfer-Encoding header is misrouted as a sticky:http-chunk of the first request. If the first request's socket is no longer tracked, the later request is silently dropped — no response, no error, indefinite hang on the client.
Happy to provide the full diagnostic logs from a live-instrumented reproduction (added temporary console.error calls to index.js and captured the exact message sequence) if useful — omitted here for brevity but readily available.
Summary
setupMaster'sworkerId/connectionIdare declared once per raw TCP connection (in the closure ofhttpServer.on("connection", (socket) => { let workerId, connectionId; ... })), not per HTTP request. Once the first request on a connection has aContent-LengthorTransfer-Encodingheader (mayHaveMultipleChunks),connectionIdis set and never reset for the rest of that connection's lifetime — even after that request completes.Any subsequent request sent over the same reused keep-alive connection then hits:
— treated as a continuation chunk of the first request, instead of being routed as a new
sticky:connection. On the worker side, if the original socket has already been removed from thesocketsmap (e.g. because the first request's response already completed and the socket was closed), the later request's entire raw bytes — headers included, not just a body — are silently dropped (sockets.get(connectionId)returnsundefined, nothing happens). The client hangs forever: no response, no error, no log output anywhere.Every browser reuses HTTP/1.1 keep-alive connections by default, so this reproduces reliably against real browser traffic (Chrome) — a client sending a POST/PATCH/PUT with a body, followed by any other request on the same connection, is enough to trigger it.
curlmostly avoids it in casual testing only because a fresh CLI invocation typically opens a fresh connection each time — it is not curl-specific, and reproduces the same way with a raw TCP connection carrying two pipelined requests.Environment
@socket.io/sticky: 1.0.4 (latest on npm as of this report)socket.io: 4.8.3@socket.io/pm2-orchestrated PM2 cluster mode (2 workers) and independently reproduced with vanilla Nodecluster+@socket.io/sticky(no PM2 involved at all), ruling out PM2 as a factor.Minimal reproduction
Steps:
node repro.jsPOSTwith a JSON body, then a second request (any method) reusing the same TCP connection — e.g. twocurlrequests piped down one raw socket, or via a real browser tab where Chrome's connection pool happens to reuse the socket. A real browser making aPOSTfollowed by any other request to the same origin is the easiest way to trigger this in practice — it reproduces reliably against actual application traffic, not just crafted pipelining.sticky:http-chunk—sockets.get(connectionId)returnsundefinedinsetupWorker, and the request is silently dropped with no error anywhere.Expected behavior
Each HTTP request on a keep-alive connection should be routed as its own logical unit —
workerId/connectionId(or equivalent per-request state) should reset once a request/response cycle completes, so a second request on a reused connection is treated as a fresh request rather than a phantom continuation of the first.Actual behavior
The second (and any later) request on a connection where the first request carried a
Content-Length/Transfer-Encodingheader is misrouted as asticky:http-chunkof the first request. If the first request's socket is no longer tracked, the later request is silently dropped — no response, no error, indefinite hang on the client.Happy to provide the full diagnostic logs from a live-instrumented reproduction (added temporary
console.errorcalls toindex.jsand captured the exact message sequence) if useful — omitted here for brevity but readily available.