Repository navigation
[@elevenlabs/client][@elevenlabs/react] Pending WebRTC start cannot be cancelled and can reject after teardown #1013
Description
Summary
There are two related lifecycle gaps during WebRTC startup.
- A publication rejection can remain unobserved.
WebRTCConnection.create()begins microphone publication fromRoomEvent.SignalConnected, but does not observe the resultingsetMicrophoneEnabled()promise (micEnabled) until afterroom.connect()and the connected-state wait finish. If publication rejects first—for example, when LiveKit's AddTrack request times out—the rejection can surface as a browserunhandledrejectionwhile startup remains pending. - A pending start cannot be cancelled. At the React layer,
endSession()and provider unmount mark an in-flightConversation.startSession()as ended and attach cleanup to its outer promise. The old LiveKit Room and microphone can therefore remain alive until that promise settles, while a replacement provider can start. A later stale failure may be omitted from applicationonErroreven if an earlier inner rejection has already escaped.
Source inspection
The ordering is present on main at effdd8abae4589cf717e11854d12c90fcea09961:
packages/client/src/utils/WebRTCConnection.tscreatesmicEnabled, awaitsroom.connect(), and then awaitsmicEnabled.packages/react/src/conversation/ConversationProvider.tsxretains only the outer start promise; pendingendSession()waits for it to settle before callingconv.endSession().
The current React tests named “cancels session if endSession is called during connection” and “allows new connection after cancelled session” resolve the pending start before asserting teardown or replacement. They cover deferred cleanup after successful resolution, not cancellation while startup remains pending.
Suggested tests to demonstrate current behavior
For the client, use a mocked LiveKit Room with independent deferred promises:
- Call
WebRTCConnection.create()and emitSignalConnected. - Leave
room.connect()pending. - Reject
setMicrophoneEnabled()withnew Error("publication of local track timed out, no response from server"). - Observe that the start promise remains pending and the rejection can reach
unhandledrejectionbeforeroom.connect()settles.
For React, leave Conversation.startSession() pending, call provider endSession() or unmount, and observe that the in-flight start is not cancelled and cleanup does not run until the outer promise settles. A replacement provider can begin while the old Room or microphone work is still pending. There is currently no public handle to cancel that start or await teardown completion before replacement.
Requested behavioral contract
- Observe rejections from both
room.connect()and microphone publication from their creation through cancellation and late settlement, so neither can become anunhandledrejectionregardless of settlement order. - Provide a public way to cancel an in-flight start and await teardown completion, through React
endSession()or an equivalent API. Provider unmount must begin cancellation synchronously even though an effect cleanup cannot await it. - On active startup failure, disconnect and clean up the Room; stop every microphone track acquired for that start, including acquisition that resolves after cancellation; allow a later start without waiting for the cancelled outer promise; and deliver the original
Errorto the application exactly once. - On intentional teardown of a stale start, perform the same cleanup without calling application
onError. - Make late callbacks and settlements from the cancelled Room inert so they cannot publish or mutate replacement-session state.
- When cancellation and active failure race, deliver exactly one documented outcome and no duplicate failure signal.
- Resolve teardown only after the cancelled start's Room cannot publish and all microphone acquisition is either cancelled or guarded so a later-acquired track is stopped before use. Calling
room.disconnect()alone should not be assumed to abort every pending LiveKit operation.
Observing both startup promises concurrently closes the unhandled-rejection window but is not sufficient on its own: without external cancellation, the old Room and microphone can still outlive React teardown until the LiveKit failure arrives.
Suggested regression and contract coverage
Client
- Microphone-first failure:
micEnabledrejects whileroom.connect()is pending. Startup rejects with the sameErrorwithout waiting forroom.connect()to settle; Room cleanup runs; nounhandledrejectionfires. - Connect-first failure:
room.connect()rejects while microphone setup is pending. Microphone and Room cleanup run; the connect error is reported once; a later microphone rejection remains observed. - Explicit cancellation: cancel while both operations are pending. Cleanup starts without waiting for either operation; teardown does not resolve before owned Room and track resources are safe; later rejections remain observed.
- Late settlement and acquisition: settle either operation, or resolve pending media acquisition, after cancellation. Every late-acquired track stops; no publication, stale callback, stale state mutation,
unhandledrejection, or second failure signal occurs.
React
endSession()during start: cancellation is invoked immediately; after teardown resolves, a later start is allowed even if the old outer promise remains pending; applicationonErroris not called.- Unmount during start: cancellation begins synchronously and late settlements remain observed even though React cannot await effect cleanup.
- Replacement ordering: after awaiting teardown, a second start sees no live old track or Room capable of publishing; stale callbacks cannot overwrite replacement state.
- Active failure and tie-breaking: a non-cancelled publication failure reaches provider
onErroronce with the original message and leaves the provider ready for a later start; cancellation/failure races follow one documented outcome without duplicate signaling.
Versions
Observed by source inspection in @elevenlabs/client@1.17.0 and @elevenlabs/react@1.12.0; still present in published @elevenlabs/client@1.25.0, published @elevenlabs/react@1.15.2, and upstream main at effdd8abae4589cf717e11854d12c90fcea09961.
Activity
sailod commentedon Sep 30, 2026
Confirming both lifecycle gaps from #1013 with a production incident we
diagnosed last week — the timeline matches the "stale failure omitted from
onError" + replacement-start gating behavior described here, and it'sreproducible from our server-side records alone.
Environment:
@elevenlabs/react1.13.0 /@elevenlabs/client1.21.0,WebRTC mode with
webRtc: { iceTransportPolicy: "relay" }(EL TURN relay),mobile Chrome on Android.
Incident timeline (from the EL conversation record + our post-call webhook
server logs — client telemetry for this window was unfortunately lost, so
every timestamp below is server-side):
09:20— Session 1 connects normally, ~255s of practice, disconnects(client network drop,
Client disconnected: 1000). Billed normally.09:25:50— User taps our app's Reconnect. Access check mints a freshconversation token (we had recently fixed an unrelated stale-token caching
bug, so the token was minted milliseconds before the attempt — not the
variable here).
09:25:51— EL creates the conversation; the agent starts its greeting(room alive, waiting).
09:26:22— 30 seconds after room creation, EL closes theconversation (
Client disconnected: 1000). Our webhook receives thepost-call event and finds no matching local record — proof the client
never finished joining and never delivered the conversation id up-stack.
no
onErrorfired, and a subsequentstartSession()call was a silentno-op until the page was reloaded.
Why we believe this maps to #1013 rather than a plain ICE failure:
LiveKit's own
peerConnectionTimeout(15s) should have rejected the joinlong before EL's 30s server close. The only ways the app ends up in
"pending forever with no
onError" are the ordering gaps this issuedescribes — the server-close path firing the provider's disconnect/status
handling while the startup rejection is still in flight, the stale/shouldEnd
gating classifying that rejection as superseded, and
lockRefneverclearing as a result (
if (lockRef.current) return;on the nextstartSession()).Operational impact for us: in addition to the stuck UI, we're billed
platform credits for the ~30s of agent greeting audio on each occurrence
(the room did play TTS to nobody). We're currently shipping extra
client/server instrumentation to capture the next occurrence with
client-side evidence, and we are watching PR #1014 with interest — the
per-start
AbortSignal+ 10-second replacement-gating release + preservedfailed-start retry look like they address exactly this failure mode.
Happy to share traces from the next instrumented repro if useful. Thanks
for the detailed source inspection in this issue — it matched our
reconstruction of the incident precisely.