Skip to main content

September 29, 2026

Superseded listen start must not call stop

Guided session app granted mic access then showed could not start listening. A stale Listen start called stop and bumped the generation counter past the winner.

Sander Korf4 min read
reactwebsocketsaudio

A guided session web app runs a structured conversation in the browser: each step shows a prompt, and the participant answers by typing or by pressing Listen for live speech-to-text. Listen is the product name for the mic path: the browser permission prompt, a short-lived token from the app backend, a MediaStream from getUserMedia, and a websocket to ElevenLabs Scribe that sends transcript chunks back into the same step. Facilitators and participants use it mid-session when speaking is faster than the keyboard.

Who hits the bug: the participant on a session step that shows Listen. Screen: the active prompt with the Listen control (not a separate settings page). Flow: tap Listen, allow the mic, wait while the app fetches the token and opens audio, then see the listening indicator and live transcript. What broke: the browser showed mic access granted, but the app displayed Could not start listening until a full refresh, even though the user did nothing wrong on the permission dialog.

Overlapping starts and generation

Listen start is async. A second start often begins before the first finishes: React remounts the component, navigation re-renders the step, or the user taps Listen twice while getUserMedia is still pending. Each start gets a generation id (a counter bumped whenever a new start begins or the user explicitly stops). After every await, the hook checks whether this start generation still matches the live generation. If not, that attempt is superseded: it lost the race and must exit without harming the newer attempt.

The failure was not missing generation checks. The superseded attempt still called stop(), the same entry point used when the user ends listening for real. stop() always increments generation and tears down the active mic and websocket. Attempt two was mid-connect when attempt one resumed, saw it was stale, and called stop(). That bumped generation again and dismantled attempt two's half-built session. The UI surfaced that as a failed start.

Stale cleanup is not user stop

User stop must invalidate every in-flight start and reset listening UI. Superseded cleanup must only release tracks, socket, and audio context that this attempt opened, without touching the global generation counter.

After each async gap (getUserMedia, token route, audio context open), stale attempts return. If audio opened on a superseded generation, closeListenHardware() tears down that attempt's hardware only. listenStartFailureAction shows an error toast only when the failing attempt is still current, so an old attempt cannot toast over a newer one.

Overlapping Listen starts (simulated)

No real mic. Two quick starts like a React remount or retry. Compare stop() on stale attempt vs close hardware only.

Guided session · voice check-in

ElevenLabs Scribe websocket (fake timeline)

generation: 1in progress
  1. gen 1

    Listen start gen 1: getUserMedia…

Step through or replay the overlapping listen starts.

Try this on the page (demo is embedded above)

The block Overlapping Listen starts (simulated) is on this article. There is no real microphone.

  1. Leave Naive (stale calls stop) selected. Click Replay timeline. Watch the step list and the generation badge: generation moves from 2 to 3 when stale work calls stop(), and the status badge ends on listening failed.
  2. Click Fixed (hardware only). Click Replay timeline again. Generation stays at 2 through the last step, and the badge ends on listening.
  3. Optional: use Next step instead of replay to click through the same story one beat at a time.

The labels mirror production order: two starts, stale path after getUserMedia, then either stop() (naive) or closeListenHardware() only (fixed).

Policy in small testable functions

Vitest table-drives the rules without a browser:

export function afterListenAsyncStep(currentGen: number, startGen: number): 'continue' | 'stale' {
  return currentGen === startGen ? 'continue' : 'stale'
}
 
export function supersededAfterAudioAction(): 'closeHardwareOnly' {
  return 'closeHardwareOnly'
}
 
export function listenStartFailureAction(currentGen: number, startGen: number): 'surfaceError' | 'ignore' {
  return currentGen === startGen ? 'surfaceError' : 'ignore'
}

The same release also forwarded token-route HTTP bodies to the client and relaxed mic constraints on OverconstrainedError. Support logs improved; the stuck-until-refresh bug was the extra generation bump from stop() on a superseded path.

Any hook that opens mic hardware asynchronously and exposes one global stop() needs two teardown verbs. Reserve stop() for user intent. Give superseded attempts a named narrow teardown that cannot advance the epoch.


Happy coding! Sander