Steven
Steven6 min read

A Dropped Connection Shouldn't Crash Your Whole App — But Ours Did

When our backend went offline mid-meeting, it didn't just pause transcription — it crashed the entire app. The cause was a single unhandled event, and the fix was ten lines. This is the release cluster that made GeekBye stay signed in and stay connected through the things that used to kill it.

Engineering
Reliability
Electron
GeekBye Releases
A Dropped Connection Shouldn't Crash Your Whole App — But Ours Did

Some bugs interrupt a feature. The worst ones take down everything around it. In an early GeekBye build, if our backend went offline while you were mid-meeting, the transcription didn't just stop — the entire app crashed. One dropped connection, and the whole window was gone.

The cluster of releases that fixed this — v1.6.8 through v1.6.11 — is a masterclass in the unglamorous work of "stay signed in, stay connected." The headline fix was ten lines. Here's what those ten lines were, and everything else that shipped alongside them.

Ten lines between a disconnect and a crash

The crash lived in the audio code. When a transcription session tore down its WebSockets, it called removeAllListeners() on them — reasonable cleanup. But here's the Node.js detail that turns cleanup into catastrophe: an error event with no listener is not ignored. It is re-thrown as an uncaught exception.

So picture the sequence when the backend goes offline: the socket has an error event queued up to fire. Cleanup runs removeAllListeners(), stripping the handler that would have caught it. A moment later the queued error fires — into a void. Node sees an error event with nobody listening, and does the only thing its contract allows: it throws it as an uncaught exception, which crashes the process. The user's backend hiccupped for two seconds and their app vanished.

The fix (v1.6.8) is almost anticlimactic. Right after stripping the listeners, re-attach a deliberate no-op error handler:

this.micWebSocket.removeAllListeners()
this.micWebSocket.on('error', () => {}) // absorb late error events

That empty function is the whole point. It gives the pending error somewhere to land — a sink — so Node has a listener and never re-throws. The same commit also cleaned up half-open sockets when a connection attempt fails partway, so a botched connect can't leave a live socket dangling with an error waiting to fire. The commit comment says it plainly: "Without this, pending errors become uncaught exceptions and crash the app." Ten lines turned a full crash into a silent, recoverable disconnect.

The lesson is worth more than the fix: any time you call removeAllListeners() on a socket or stream you might still be tearing down, re-attach a no-op error sink first. An unhandled error event is a crash, not a log line.

Staying signed in: refresh ahead, and refresh on the bounce

The "stay connected" half had a "stay signed in" twin. Before this cluster, GeekBye's auth token simply expired — after a while you'd get unceremoniously dumped back to the login screen, mid-workflow, for no reason you could see.

v1.6.8 fixed this from both directions:

  • Proactive: a timer refreshes the token before it expires, scheduled at expiry minus a smart buffer (a fraction of the token's lifetime, floored and capped). You're refreshed before you'd ever notice.
  • Reactive: if a request comes back 401 anyway — clock skew, a missed timer, a cold laptop — the client refreshes once and retries the original request exactly once. A single guard flag caps that retry so a persistent 401 can never spin into an infinite loop.

The subtle-but-critical part: both paths are behind a mutex. If ten requests hit a 401 at the same instant, they don't fire ten refreshes — they all wait on the one refresh in flight and then retry. And when a refresh genuinely can't be saved, the app expires the session gracefully — a clear ten-second "your session expired, please log in again" toast — instead of a silent kick to the login screen.

Reconnection: add it on the client, then move it to the backend

v1.6.9 made a dropped transcription connection recoverable instead of fatal. When the backend reported the upstream speech connection had dropped, the client stopped surfacing a fatal error and instead reconnected — with deliberately calm UX: a single amber "Reconnecting…" toast (not one per attempt), a green "Reconnected" toast when it came back, and the terminal "please restart" error only after every attempt was exhausted.

Then something I like about this cluster: one release later, in v1.6.10, we deleted that client-side reconnection logic — about 113 lines — and moved reconnection to the backend, which now emits quiet "reconnecting" status messages the client just reflects. We built recovery on the client, decided the backend was the right owner, and moved it. That's not waffling; that's the honest lifecycle of a hard problem. Reliability logic split across both ends of a connection is a smell — pick an owner. (The backend eventually became the definitive owner of this, which is the reconnection story told in why your AI notetaker stops on bad Wi-Fi.)

The same release made recording-time limits humane: instead of a raw error, a warning toast that lets recording continue, and a clear "Recording Limit Reached" message when it actually stops — the friendly sentence surfaced, the internal prefix stripped.

One retry to rule them all

By v1.6.11, the same retry-with-backoff logic had been copy-pasted into three different places — auth, updates, session teardown. So it got consolidated into a single module with one shared rule about what's even worth retrying: transient network failures (connection reset, timeout, DNS hiccup, socket hang-up) get retried with exponential backoff; a real server error fails fast, because retrying a genuine 4xx just wastes the user's time. One classifier, a few named presets, three callers migrated onto it.

What this cluster taught

  1. An unhandled error event is a crash, not a log line. Node re-throws error events that have no listener. Strip listeners on a socket you might still be tearing down, and you must re-attach a no-op error sink — or one dropped connection takes the whole app with it.
  2. Refresh credentials ahead of expiry and reactively on a 401 — with a mutex on both. Proactive keeps you signed in; reactive catches the edge cases; the mutex means N simultaneous callers cause exactly one refresh, and a retry cap means a bad token can't loop forever.
  3. Retry only on transient errors, and centralize the classifier. "Is this worth retrying?" is one decision that belongs in one place. Retrying a real error is just a slower failure.
  4. Decide who owns reconnection. We added it on the client, then moved it to the backend. Recovery logic living on both ends is a bug generator; give it a single home.

This is the third chapter of the reliability story that becomes GeekBye v2. For the previous chapter, see we deleted 5,000 lines of audio code and transcripts started showing up twice (v1.6.0); for where reconnection eventually settled, why your AI notetaker stops on bad Wi-Fi; and for the whole arc, the anatomy of shipping software to perfection.