Title
Implement connection draining with pending message flush for graceful shutdown — prevent data loss during deploys and SIGTERM
Difficulty
9/10 — Expert. Estimated effort: 2–3 days for a senior engineer.
Context
The current shutdown() function in src/index.js (lines 43–53) calls wss.close() and sets a 5-second force-exit timer. wss.close() stops accepting new connections but does not close existing client connections or wait for pending sends to complete. In a fleet-tracking deployment, at any given moment there may be hundreds of in-flight location broadcasts queued in WebSocket send buffers. When wss.close() is called, these pending bytes are silently dropped. The 5-second force exit then kills the process before clients can receive close frames.
This means every deploy (rolling update, Docker restart, Kubernetes pod eviction) causes:
- Silent data loss of all pending location updates.
- No close frame sent to clients — they must wait for TCP timeout (typically 30–120 seconds) to detect the disconnect, during which they appear as "connected" but receive nothing.
- No room membership cleanup events — other clients in the same rooms don't know a member disconnected until the heartbeat reaper catches it.
For a fleet-tracking system, this means vehicles appear frozen on the map for up to 2 minutes after a deploy, and location history has gaps.
Problem statement
Implement a multi-phase graceful shutdown that:
- Phase 1 — Stop accepting (0–100ms): Close the HTTP upgrade listener so no new WebSocket connections are established. Existing connections remain open.
- Phase 2 — Notify clients (100ms–500ms): Send a
{ type: "server_shutting_down", payload: { reconnectIn: N } } message to all connected clients, giving them time to reconnect to another instance.
- Phase 3 — Drain pending sends (500ms–4000ms): For each connected client, flush any buffered outgoing data. Use
ws.bufferedAmount === 0 as the drain signal. Clients that haven't drained within this window are terminated.
- Phase 4 — Close connections (4000ms–5000ms): Send WebSocket close frames (code 1001 "Going Away") to all remaining clients. Wait for close acknowledgements.
- Phase 5 — Force exit (>5000ms): If any clients are still connected, force
process.exit(1).
The shutdown must also emit structured log entries at each phase transition for observability.
Current behavior
src/index.js lines 43–53:
function shutdown(signal) {
logger.info("Shutting down", { signal });
wss.close(() => {
logger.info("Server closed");
process.exit(0);
});
setTimeout(() => {
logger.error("Forced shutdown");
process.exit(1);
}, 5000);
}
wss.close() stops accepting but doesn't close existing connections.
- No drain phase — pending sends are dropped.
- No client notification — clients don't know the server is shutting down.
- The 5-second timer fires unconditionally — even if clients have already disconnected.
- No structured log entries per phase.
Required behavior
shutdown(wss, signal) implements the 5-phase draining protocol.
- All connected clients receive a shutdown notification before connections are closed.
- Pending send buffers are drained with a bounded timeout.
- Clients receive proper close frames (code 1001).
- The process exits 0 on clean shutdown, 1 on forced.
- Structured log entries at each phase: "shutdown: stopping accept", "shutdown: notifying N clients", "shutdown: draining N clients", "shutdown: closing N clients", "shutdown: force exit".
Constraints
- Do not change the
SIGTERM/SIGINT signal handlers — they must still call shutdown().
- Do not add new npm dependencies.
- Do not modify
server.js — shutdown() receives the wss instance and operates on it.
- Do not modify existing test files. The existing
shutdown tests in index.test.js must still pass (they test the current behavior with a mock WSS — ensure backward compatibility).
- The total shutdown timeline must not exceed 5 seconds (the existing force-exit budget).
- Must handle: clients that never respond to close frames, clients with broken TCP connections, clients that reconnect during shutdown.
Acceptance criteria
Out of scope
- Changes to
server.js, room-manager.js, auth.js, validator.js, rate-limiter.js, logger.js.
- Implementing a health check endpoint (that is a separate concern).
- Zero-downtime deploy orchestration (that requires a load balancer and is outside this service's scope).
- Persisting pending messages to disk or a queue during shutdown.
Hints and references
wss.clients is a Set<WebSocket> of all connected clients — iterate it for the drain phase.
ws.bufferedAmount (RFC 6455 §5.2) returns bytes queued. Poll it with setInterval(100) during the drain phase.
- WebSocket close code 1001 ("Going Away") is the standard code for server-initiated shutdown (RFC 6455 §7.4.1).
- The notification message
{ type: "server_shutting_down" } is not part of the current protocol — you'll need to add it to the messageSchema in validator.js... except issue constraints say not to modify validator.js. Solution: send the notification as a raw JSON string bypassing validation (use ws.send(JSON.stringify(...)) directly, not through the validated message path).
- Consider: what if
wss.close() is called while a drain is in progress? The existing test expects wss.close to be called. Ensure both the drain AND the wss.close() happen.
Title
Implement connection draining with pending message flush for graceful shutdown — prevent data loss during deploys and SIGTERM
Difficulty
9/10 — Expert. Estimated effort: 2–3 days for a senior engineer.
Context
The current
shutdown()function insrc/index.js(lines 43–53) callswss.close()and sets a 5-second force-exit timer.wss.close()stops accepting new connections but does not close existing client connections or wait for pending sends to complete. In a fleet-tracking deployment, at any given moment there may be hundreds of in-flight location broadcasts queued in WebSocket send buffers. Whenwss.close()is called, these pending bytes are silently dropped. The 5-second force exit then kills the process before clients can receive close frames.This means every deploy (rolling update, Docker restart, Kubernetes pod eviction) causes:
For a fleet-tracking system, this means vehicles appear frozen on the map for up to 2 minutes after a deploy, and location history has gaps.
Problem statement
Implement a multi-phase graceful shutdown that:
{ type: "server_shutting_down", payload: { reconnectIn: N } }message to all connected clients, giving them time to reconnect to another instance.ws.bufferedAmount === 0as the drain signal. Clients that haven't drained within this window are terminated.process.exit(1).The shutdown must also emit structured log entries at each phase transition for observability.
Current behavior
src/index.jslines 43–53:wss.close()stops accepting but doesn't close existing connections.Required behavior
shutdown(wss, signal)implements the 5-phase draining protocol.Constraints
SIGTERM/SIGINTsignal handlers — they must still callshutdown().server.js—shutdown()receives thewssinstance and operates on it.shutdowntests inindex.test.jsmust still pass (they test the current behavior with a mock WSS — ensure backward compatibility).Acceptance criteria
index.test.jsexisting tests pass:shutdown()callswss.close()andprocess.exit(0), force exit after 5s if not closedshutdown()sends shutdown notification to all connected clients before closingshutdown()waits forbufferedAmount === 0before closing each clientshutdown()sends close frame with code 1001npm run lintpassesnpm testpasses (all suites)Out of scope
server.js,room-manager.js,auth.js,validator.js,rate-limiter.js,logger.js.Hints and references
wss.clientsis aSet<WebSocket>of all connected clients — iterate it for the drain phase.ws.bufferedAmount(RFC 6455 §5.2) returns bytes queued. Poll it withsetInterval(100)during the drain phase.{ type: "server_shutting_down" }is not part of the current protocol — you'll need to add it to themessageSchemainvalidator.js... except issue constraints say not to modifyvalidator.js. Solution: send the notification as a raw JSON string bypassing validation (usews.send(JSON.stringify(...))directly, not through the validated message path).wss.close()is called while a drain is in progress? The existing test expectswss.closeto be called. Ensure both the drain AND thewss.close()happen.