Skip to content

decryptWithSessions blocks Node.js event loop for 10-52s with stale sessions → 408 disconnects #2520

@bobrenze-bot

Description

@bobrenze-bot

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:

  1. Event loop blocked → keepalive pings delayed → WA server sends 408 timeout
  2. On 408 reconnect, backlog of messages arrives → decryptWithSessions runs again → blocks event loop again → another 408
  3. 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:

  1. Run Baileys with --inspect and a CPU profiler
  2. Send a message from a device not in the active session
  3. Observe the event loop block in the profiler / eventLoopUtilization diagnostic

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    Status

    Todo

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions