Build notes · Reliability
The WhatsApp session that logged itself out 377 times
A WhatsApp Web session on our desktop product spent a night emitting QR codes. Three hundred and seventy-seven of them. Nobody was there to scan any of them, and each cycle made the next one more likely.
The obvious reading, and the one every article about unofficial WhatsApp libraries will hand you, is that WhatsApp detected automation and punished it. That was not what happened. The device was logged out because eleven of our own Chromium processes were alive at the same time, all holding the same authentication directory, all politely taking the session away from one another. WhatsApp did the only sensible thing available to it.
TL;DR
SIGKILL is asynchronous. Signalling a browser process and immediately spawning its replacement routinely runs both at once, because the operating system has not yet reaped the first one or released its file locks.
Leaked browsers share one auth directory. whatsapp-web.js sees a conflicting session and invokes takeoverOnConflict. With several of your own clients alive, they take the session from each other in a loop.
WhatsApp responds by logging the device out. Auth is then gone, and only a human scanning a QR code can restore it, so an unattended reconnect loop regenerates codes forever and never recovers.
Three fixes: a kill that polls until the PIDs are confirmed gone, a per-session mutex so teardown always completes before the next spawn, and a relink policy that parks a dead session instead of generating QR codes at nobody.
The cascade
Reading back through the log, the failure has a strict order. Every step is individually reasonable and the sequence is fatal.
LocalAuth directory, because they are all the same session id.takeoverOnConflict against what are, in fact, our own zombies.EBUSY, because a signalled-but-living browser still has it open. A half-removed auth directory is worse than either outcome.The thing worth sitting with is step 5. We spent time looking for what we had done to trip WhatsApp's automation detection, and the answer was nothing. The platform behaved correctly. The logout was entirely self-inflicted, and no amount of gentler sending patterns or randomised delays would have prevented it.
Why the kill was not a kill
The root cause is one line of assumption. Calling process.kill(pid, 'SIGKILL') returns immediately, and it is tempting to read that return as "the process is gone". It is not. It means the signal was delivered. The operating system may take hundreds of milliseconds to reap the process, and until it does, that process still holds its file handles, its SingletonLock, and its share of the auth directory.
So the fix is not a better signal, it is a confirmation. Signal every candidate PID, then poll until they are all actually gone.
src/local-modules/wa-session-supervisor.mjs
const deadline = Date.now() + Math.max(pollIntervalMs, timeoutMs);
let survivors = targets;
while (Date.now() < deadline) {
survivors = survivors.filter((pid) => checkAlive(pid));
if (!survivors.length) return { confirmed: true, survivors: [] };
await sleep(pollIntervalMs);
}
One detail in checkAlive is easy to get backwards. Probing a process with signal 0 throws when the process is gone, but it also throws EPERM when the process exists and you are not allowed to signal it. Treating every throw as "dead" reintroduces the exact leak, so EPERM has to be read as alive:
try {
processKill(pid, 0);
return true;
} catch (error) {
// EPERM = exists but not signalable (still alive); anything else = gone.
return Boolean(error && error.code === 'EPERM');
}
The function returns { confirmed, survivors } rather than a boolean, because a caller that could not kill a browser needs to know which PIDs are still standing in order to decide whether spawning is safe at all.
Serialising the lifecycle
A verified kill removes the leak only if nothing spawns while it is running. The invariant we actually needed was stronger than "kill properly": at most one live client and one Chromium per session id, and no new spawn until the previous browser is confirmed dead.
That is a mutual exclusion problem, keyed per session. A bare promise chain is the tempting shortcut and it has a nasty property: one rejected task poisons every task queued behind it, so a single failed teardown would wedge that session permanently. The lock therefore swallows the previous task's failure while still waiting for it to finish:
await previous.catch(() => undefined);
try {
return await task();
} finally {
release();
if (chains.get(k) === gate) chains.delete(k);
}
The last two lines matter more than they look. Without dropping the entry when the current task is the tail, the map of per-session chains grows for the lifetime of the process, which is a slow leak introduced by the fix for a fast one.
Refusing to generate QR codes at nobody
The last fix is a policy rather than a mechanism, and it is the one we would have benefited from first. After WhatsApp logs a device out, the stored auth is gone. No amount of retrying reconnects it. Only a human scanning a QR code can, and if no human is present the loop simply runs until somebody notices.
So the supervisor now parks sessions instead of retrying them forever. Two rules, both derived from what the log actually showed:
- A logout storm parks immediately. Two or more logouts inside ten minutes is not a person unlinking their phone. It is clients fighting, and continuing to reconnect makes it worse.
- One relink window, then park. After a logout the session gets a single window of six unscanned QR codes. If none is scanned, it moves to
needs_relinkand stops. A manual reconnect resets the window, so a user is never locked out of trying again.
The important property is that needs_relink is a visible state rather than a silent stall. A session that has stopped trying and says so is recoverable in a minute. A session generating its 300th QR code looks busy and is not.
What this actually costs
We run both sides of the WhatsApp world in one product. The desktop application drives whatsapp-web.js sessions against personal numbers, and the cloud application talks to Meta's official Cloud API for business numbers. That makes the comparison concrete rather than rhetorical.
None of the failure above is possible on the Cloud API. There is no browser, no auth directory, no device session and no QR code, so there is nothing to leak and nothing to log out. The trade is straightforward once it is stated plainly: the unofficial libraries cost nothing per message and carry an operational surface that is invisible until it fails at two in the morning. The official API removes that surface entirely and bills per message instead.
The public discussion of unofficial WhatsApp libraries is almost entirely about ban risk, which is real. What gets left out is the ordinary running cost, and this note is one night of it. If you are weighing the two paths, price the supervision you will have to write, not just the messages you will not have to pay for.
Running whatsapp-web.js and thinking about the official API?
Klaros operates both. The desktop side keeps personal-number sessions alive, and the cloud side sends through Meta's Cloud API on your own WhatsApp Business Account, billed by Meta directly with no per-message markup in between. Your contacts and groups carry across, because Klaros syncs those itself. Your conversation history does not, and no vendor can honestly promise otherwise: Meta does not permit prior conversations to be imported into the Cloud API at all.
+91 97893 77634 · you message first, so nothing reaches you without your say-so.
Questions people ask about whatsapp-web.js session failures
Why does whatsapp-web.js keep showing a new QR code instead of reconnecting?
A repeating QR code means the stored authentication is gone, not that the connection is slow. The usual cause is that more than one browser process is holding the same LocalAuth directory: whatsapp-web.js sees a conflicting session, invokes takeoverOnConflict, and the clients fight until WhatsApp logs the device out. Once that happens only a human scanning a QR can restore it, so an automatic reconnect loop will regenerate codes indefinitely and never succeed.
Why did WhatsApp log out my device when I was not sending anything?
A forced logout is not always Meta detecting automation. If two or more of your own Chromium processes are alive against one auth directory, they take the session from each other in turn, and WhatsApp treats that pattern as a device it cannot trust. In our case eleven leaked browsers were alive at once and the logout was entirely self-inflicted.
Is calling process.kill enough to stop a leaked Chromium?
No. SIGKILL is asynchronous: the operating system may take hundreds of milliseconds to reap the process and release its file locks, including the LocalAuth directory and SingletonLock. Code that signals a process and immediately spawns a replacement will routinely run both at once. The kill has to be verified by polling the PID until it is genuinely gone.
What does an EBUSY error on the WhatsApp auth directory mean?
It means something still has the directory open, which on Windows is almost always a browser process that was signalled but has not exited yet. Deleting the auth directory while it is held either fails or half-succeeds, and a half-deleted auth directory produces exactly the QR loop it was meant to fix. Retrying the removal with a short backoff, after confirming the processes are dead, is the reliable order.
Does the official WhatsApp Cloud API have this failure mode?
No. The Cloud API is hosted by Meta and holds no browser, no auth directory and no device session, so there is nothing to leak and no device to log out. That is the trade being made: the unofficial libraries cost nothing per message and carry this operational surface, while the Cloud API removes the surface and bills per message. The failure documented here is a running cost of the unofficial path that is rarely priced in.
Written 3 September 2026 from server.log of 1–2 July 2026. We append when the facts change. Related: what a WhatsApp BSP actually sells, the Cloud API error codes and what to do with each one, all build notes.
