Summary
SessionCipher.decryptWithSessions() in @whiskeysockets/libsignal-node iterates through all stored sessions synchronously, running full Signal Protocol crypto on each attempt. When there are many stale sessions and messages arrive that fail to decrypt (MessageCounterError: Key used already or never filled), the loop exhausts all sessions before throwing — blocking the Node.js event loop for 10–52 seconds in production.
Impact
This causes a feedback loop that many users will recognise:
- Event loop blocked → keepalive pings delayed → WA server sends 408 timeout
- On 408 reconnect, backlog of messages arrives →
decryptWithSessions runs again → blocks event loop again → another 408
- Repeat indefinitely
The 408 disconnect issues in this repo are frequently caused by this — not by network conditions.
Measured impact (production, Node 20):
eventLoopDelayP99Ms: 52,244ms → 134ms after fix (390× improvement)
WebSocket 408 disconnects: every 2 min → every 10–20 min after fix
- All async operations on the same event loop (HTTP requests, timers, other WebSocket connections) stalled for the duration
Root Cause
In node_modules/@whiskeysockets/libsignal-node/src/session_cipher.js, decryptWithSessions is a for...of loop with no await between iterations that would yield to the event loop:
async decryptWithSessions(data, sessions) {
const errs = [];
for (const session of sessions) {
let plaintext;
try {
plaintext = await this.doDecryptWhisperMessage(data, session); // CPU-heavy crypto
// ...
} catch(e) {
errs.push(e);
}
}
// ...
}
doDecryptWhisperMessage is CPU-intensive JS (elliptic curve crypto). await on it does not yield to the event loop — it only yields to microtasks. With 10–50 sessions × CPU crypto per session, the event loop is blocked until the entire loop completes.
Fix
Add a setImmediate yield before each attempt. This allows pending macrotasks (timers, I/O callbacks, keepalive pings) to run between session attempts:
async decryptWithSessions(data, sessions) {
if (!sessions.length) {
throw new errors.SessionError("No sessions available");
}
const errs = [];
for (const session of sessions) {
// Yield to event loop between attempts so keepalive timers can fire.
// Without this, N sessions × crypto blocks the event loop for tens of seconds.
await new Promise(resolve => setImmediate(resolve));
let plaintext;
try {
plaintext = await this.doDecryptWhisperMessage(data, session);
session.indexInfo.used = Date.now();
return { session, plaintext };
} catch(e) {
errs.push(e);
}
}
console.error("Failed to decrypt message with any known session...");
for (const e of errs) {
console.error("Session error:" + e, e.stack);
}
throw new errors.SessionError("No matching sessions found for message");
}
The fix is one line. It doesn't change decryption logic or session ordering — it just lets the event loop breathe between attempts.
Upstream
This fix should land in @whiskeysockets/libsignal-node. An issue has been filed there: WhiskeySockets/libsignal-node#18
Until it's merged, users can patch the file locally at:
node_modules/@whiskeysockets/libsignal-node/src/session_cipher.js
Reproduction
Any Baileys app with multiple stale Signal sessions will hit this. Trigger by:
- Run Baileys with
--inspect and a CPU profiler
- Send a message from a device not in the active session
- Observe the event loop block in the profiler /
eventLoopUtilization diagnostic
Summary
SessionCipher.decryptWithSessions()in@whiskeysockets/libsignal-nodeiterates through all stored sessions synchronously, running full Signal Protocol crypto on each attempt. When there are many stale sessions and messages arrive that fail to decrypt (MessageCounterError: Key used already or never filled), the loop exhausts all sessions before throwing — blocking the Node.js event loop for 10–52 seconds in production.Impact
This causes a feedback loop that many users will recognise:
decryptWithSessionsruns again → blocks event loop again → another 408The 408 disconnect issues in this repo are frequently caused by this — not by network conditions.
Measured impact (production, Node 20):
eventLoopDelayP99Ms: 52,244ms → 134ms after fix (390× improvement)WebSocket 408 disconnects: every 2 min → every 10–20 min after fixRoot Cause
In
node_modules/@whiskeysockets/libsignal-node/src/session_cipher.js,decryptWithSessionsis afor...ofloop with noawaitbetween iterations that would yield to the event loop:doDecryptWhisperMessageis CPU-intensive JS (elliptic curve crypto).awaiton it does not yield to the event loop — it only yields to microtasks. With 10–50 sessions × CPU crypto per session, the event loop is blocked until the entire loop completes.Fix
Add a
setImmediateyield before each attempt. This allows pending macrotasks (timers, I/O callbacks, keepalive pings) to run between session attempts:The fix is one line. It doesn't change decryption logic or session ordering — it just lets the event loop breathe between attempts.
Upstream
This fix should land in
@whiskeysockets/libsignal-node. An issue has been filed there: WhiskeySockets/libsignal-node#18Until it's merged, users can patch the file locally at:
node_modules/@whiskeysockets/libsignal-node/src/session_cipher.jsReproduction
Any Baileys app with multiple stale Signal sessions will hit this. Trigger by:
--inspectand a CPU profilereventLoopUtilizationdiagnostic