Socket.io client and Node.js server: coordinating acknowledgment timeouts with disconnect cleanup
0 reputation · 03 Feb 2023, 18:04 UTC
0 reputation · 03 Feb 2023, 18:04 UTC
I am designing the request/response layer between a Socket.io browser client and a Node.js server (Socket.io v4 assumed on both ends). The client emits events with an acknowledgment timeout, and the server performs work that may outlive that timeout. I need a consistent policy for what happens on each side when the acknowledgment expires or the connection drops mid-request.
From the documented behavior, an acknowledgment timeout only invokes the client callback with an error; it does not signal the server to stop processing, and there is no built-in cancellation propagation comparable to AbortController. Separately, the heartbeat settings (pingInterval/pingTimeout) control when a silent connection becomes a disconnect, which is my trigger for cleaning up pending work and listeners. That leaves two independent timers racing: the per-emit acknowledgment timeout and the transport-level disconnect detection.
The unresolved part is how to keep server-side state consistent across both paths without leaking listeners or double-completing work, especially when the client reconnects and may retry the same request while the original server-side operation is still running.
disconnect alone, or is a separate application-level timeout recommended when pingTimeout is much longer than the acknowledgment timeout?32775 reputation · 04 Feb 2023, 02:28 UTC
requestId (UUID) on the client, include it in the emit payload, and register the in-flight operation on the server keyed by (socket.id, requestId). On client timeout, emit an explicit cancel event carrying that requestId; the server removes the pending entry and signals cancellation via an AbortController.disconnect primarily, but add a per-request server timer when pingTimeout ≫ acknowledgment timeout. The disconnect handler iterates all pending operations for that socket.id and aborts them. If the heartbeat window is much larger than your application timeout (e.g., pingTimeout=20s vs. ackTimeout=2s), also start a setTimeout per request that mirrors the client deadline and invokes the same abort path. This prevents orphaned work when the client times out but the socket stays alive.requestId, not socket.id. On reconnect the client receives a new socket.id. Include the original requestId in any retry emit. The server maintains an LRU map requestId → {status, result, waiters[]} that survives disconnects. On incoming request: if completed, return cached result; if in-flight, attach the new socket as an additional waiter; if unknown, start fresh work.const requestId = crypto.randomUUID();
const controller = new AbortController();
function emitWithAck(event, payload, timeoutMs = 2000) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
controller.abort();
if (socket.connected) socket.emit('cancel', { requestId });
reject(new Error('Acknowledgment timeout'));
}, timeoutMs);
socket.emit(event, { ...payload, requestId }, (err, res) => {
clearTimeout(timer);
if (err) reject(err); else resolve(res);
});
});
}
// Store requestId + retryCount in localStorage for page-reload retries
localStorage.setItem('pendingRequest', JSON.stringify({ requestId, retryCount: 0 }));const pending = new Map(); // socket.id → Map
const dedup = new Map(); // requestId → { status: 'pending'|'done', result, waiters: Set }
const DEDUP_TTL = 5 * 60 * 1000;
function register(socket, requestId, workFn) {
const controller = new AbortController();
const timer = setTimeout(() => cleanup(socket.id, requestId, 'server-timeout'), APP_ACK_TIMEOUT);
if (!pending.has(socket.id)) pending.set(socket.id, new Map());
pending.get(socket.id).set(requestId, { controller, timer });
const entry = dedup.get(requestId);
if (entry?.status === 'done') return entry.result;
if (entry?.status === 'pending') {
entry.waiters.add(socket);
return new Promise((resolve, reject) => {
entry.waiters.add(socket);
// resolve when work completes
});
}
dedup.set(requestId, { status: 'pending', waiters: new Set([socket]) });
const promise = workFn(controller.signal).finally(() => {
clearTimeout(timer);
cleanup(socket.id, requestId, 'completed');
});
return promise;
}
function cleanup(socketId, requestId, reason) {
const sockMap = pending.get(socketId);
if (sockMap) {
const op = sockMap.get(requestId);
if (op) { op.controller.abort(); clearTimeout(op.timer); sockMap.delete(requestId); }
if (sockMap.size === 0) pending.delete(socketId);
}
// dedup entry handled separately on completion
}
socket.on('cancel', ({ requestId }) => cleanup(socket.id, requestId, 'client-cancel'));
socket.on('disconnect', (reason) => {
const sockMap = pending.get(socket.id);
if (sockMap) sockMap.forEach((_, reqId) => cleanup(socket.id, reqId, `disconnect:${reason}`));
});
// Periodic dedup eviction
setInterval(() => {
const now = Date.now();
for (const [id, entry] of dedup) if (now - entry.timestamp > DEDUP_TTL) dedup.delete(id);
}, 60_000);ackTimeout option only governs handshake, not per-emit acknowledgments.cancel event from reaching the server; the per-request server timer is the safety net.What is your current pingInterval/pingTimeout configuration? If pingTimeout is already close to your application acknowledgment timeout, the extra per-request server timer adds little value and can be omitted.
Use comments to ask for clarification. Post a solution as an answer.
No question comments on this page.