A user emailed me a screen recording. The AI voice agent asks a hard question. The user goes quiet for about a minute to think. Then they start talking, and nothing happens. A second later the app reconnects on its own and the agent cheerfully asks the first question again.
That loop came from the AWS ALB idle timeout on WebSocket connections. No exceptions, no 5xx in the dashboards, just a load balancer doing exactly what it was configured to do. It dropped 58 of my 412 voice sessions in one week. That's 14%.
idle_timeout, and make reconnects resume the session instead of restarting it.
The ALB idle timeout is a timer on the connection that resets whenever a byte moves in either direction. If 60 seconds pass with zero traffic, the ALB closes both sides. It doesn't care that the HTTP request upgraded to a WebSocket. A quiet socket is a quiet socket.
Most WebSocket apps never hit this because something is always chatting: a chat app's typing indicators, a dashboard's price ticks. My app had long, legitimate silences. That was the whole product.
The system is a voice interview simulator. Browser mic goes over a WebSocket to a Node server running the ws library behind an ALB. The server transcribes, calls the LLM, and streams TTS audio back.
To save bandwidth and transcription cost, the client runs voice activity detection locally and only sends audio frames while the user is speaking. Silence sends nothing.
So the traffic looks like this:
Step 3 is where the timer runs. On easy questions people for 5 seconds. On a hard system design question, they think for 70.
Only sessions with a silent gap longer than 60 seconds dropped, and one interviewer style accidentally protected itself. That's the detail that took me longest to spot.
The product has 3 interviewer styles. The "friendly" style sends a gentle "take your time" nudge after 45 seconds of silence. That nudge is TTS audio, which is traffic, which resets the ALB timer. The "strict" style sits in silence on purpose, because that's what a strict interviewer does.
From one week of logs:
| Metric | Count |
|---|---|
| Total sessions | 412 |
| Sessions with a silent gap over 60s | 71 |
| Of those, sessions using the friendly style | 13 |
| Dropped sessions | 58 |
| Dropped sessions with a silent gap over 60s | 58 |
All 58 drops happened during a gap over 60 seconds. All 13 sessions that survived a long gap were friendly-style ones where the 45-second nudge kept the pipe warm. I had shipped a load balancer workaround as a personality trait and didn't know it.
I was logging the close event, but I wasn't logging when the last frame moved. Once I added lastFrameAt (updated on every message in either direction) the pattern fell out of one query:
ws.on('close', (code) => {
const idleMs = Date.now() - ws.lastFrameAt;
log.info({ sessionId, code, idleMs }, 'ws closed');
});
Every abnormal close had code: 1006 and idleMs between 60,000 and 61,400. Not "around a minute". A cliff at exactly 60 seconds with about a second of jitter. When your failure distribution has a cliff at a round number, look for a config default before you look for a bug.
Code 1006 means the connection closed without a close frame. The ALB drops the TCP connection rather than performing a WebSocket close handshake, so neither the browser nor the ws server ever receives a proper close frame. Both sides report 1006, the same code you get when Wi-Fi dies.
That's why this hid in my error tracking for weeks. It was bucketed with "flaky user networks", a category everyone learns to ignore.
My client had auto-reconnect with backoff, which felt responsible. But the server treated every new socket as a new session. So the user experience was: you think hard, you start answering, and the agent restarts from question 1 like nothing happened.
That's worse than a visible error. A visible error at least tells you something broke.
Full disclosure: I built this. It's Preterview, a voice interview practice app with 3 interviewer styles that scores a resume or portfolio and returns a written report. Long thinking s are normal there, which is exactly why this timeout bit so hard.
Send traffic more often than the idle timeout, from the server, using WebSocket ping frames. The browser answers pings with pongs automatically, both frames count as traffic for the ALB, and you get dead-connection detection for free.
One trap: the browser WebSocket API cannot send protocol-level ping frames. If you want a client-side heartbeat, you have to send an application message. Server-side ping is simpler and covers every client.
The ws library doesn't ping by default. Here's the heartbeat I added:
const HEARTBEAT_MS = 25_000;
wss.on('connection', (ws) => {
ws.isAlive = true;
ws.on('pong', () => { ws.isAlive = true; });
});
setInterval(() => {
for (const ws of wss.clients) {
if (!ws.isAlive) { ws.terminate(); continue; }
ws.isAlive = false;
ws.ping();
}
}, HEARTBEAT_MS);
25 seconds gives two chances to land a ping inside one 60-second window, so a single delayed frame doesn't kill the session.
Yes, but as a safety margin, not as the fix. The ALB idle timeout can go up to 4,000 seconds. I set it to 300:
resource "aws_lb" "voice" {
name = "voice-ws"
load_balancer_type = "application"
idle_timeout = 300
}
Relying on the timeout alone is fragile. There may be other hops in the path (a corporate proxy, a CDN, a mobile carrier NAT) with their own idle timers you don't control. The heartbeat survives all of them. The bigger timeout just means one missed ping isn't fatal.
The third change: the server now issues a resume token on connect. The client stores it in memory and sends it on reconnect. If the session is still in the server's state store, the client rejoins at the same question with the transcript intact. Tokens expire after 5 minutes.
Now a real network blip costs the user a second of audio, not their whole session.
| Metric | Before | After |
|---|---|---|
| Sessions | 412 | 436 |
| Dropped sessions | 58 (14.1%) | 2 (0.5%) |
Drops with idleMs near 60,000 |
58 | 0 |
| Drops from real network loss | 0 logged | 2 |
The heartbeat cost is a ping and a pong every 25 seconds, a few bytes each. Against a stream of audio frames, it doesn't show up in any bill I can read.
Caveat: two weeks of traffic at this volume is a small sample. The 0 after-fix timeout drops is convincing because the before-fix failures were so mechanical, not because 436 is a big number.
lastFrameAt for every socket and compute idle time at close. This one number would have found the bug on day one.
The AWS ALB idle timeout closes any connection, including an upgraded WebSocket, after 60 seconds with no data in either direction. Apps that go genuinely quiet, like VAD-gated voice streams during a user's , cross that line and get dropped with close code 1006 and no close frame. The fix is a server-side WebSocket ping every 20 to 30 seconds, an idle_timeout raised as a margin, and reconnect logic that resumes the session. For me that took drops from 58 of 412 sessions to 2 of 436.
Written by the developer behind Preterview, an interview prep platform.