socketio / socketio/socket.io-sticky
connectionId is scoped per-connection, not per-request — misroutes/drops later requests on a reused HTTP/1.1 keep-alive connection
Nobody has claimed this yet.
- Dominant language
- JavaScript
- Stars
- 45
- Forks
- 13
- PR merge metrics
- No merged PRs in 30d
Description
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 Nodecluster+@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
POSTwith 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. - 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)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-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.
Contributor guide
No contributing guide indexed for this repository
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in index.js at setupMaster's connection handler and trace the worker-side setupWorker handling of sticky:http-chunk and sticky:connection messages. Run the supplied repro with two requests over one reused TCP connection; done means each keep-alive request is routed independently and the second request receives a response instead of hanging.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- javascript
- Domain
- backend, distributed-systems, networking
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 52/100