Skip to content

TypeError: Cannot read property 'Closing' of undefined inside RTCEngine.negotiate() finally block on Hermes/React Native (livekit-client@2.17.2) #1952

@jaiheek

Description

@jaiheek

Description

livekit-client@2.17.2 running under React Native + Hermes (Android) reports a recurring TypeError: Cannot read property 'Closing' of undefined that originates fully inside livekit-client.umd.js. The throw happens in the finally block of RTCEngine.negotiate() when it tries to remove its own listeners after the negotiate body rejects.

The TypeError is captured by Sentry's onunhandledrejection handler — the app doesn't crash, but the underlying negotiation has already failed and the cleanup itself now also throws, masking the original error.

We are seeing this across 258 unique users / 715 events over ~2 months in production. It first appeared in our 1.3.0 release (mid-March 2026) and is still firing in 1.3.6. Steady drip, not a single bad release — strongly suggesting a runtime/device-specific UMD-on-Hermes interaction.

Environment

  • livekit-client: 2.17.2 (livekit-client.umd.js build)
  • @livekit/react-native: ^2.9.5
  • @livekit/react-native-webrtc: ^137.0.2
  • React Native + Hermes (Android), Expo SDK
  • Affected devices observed: low/mid-tier Android tablets and phones (e.g. Lenovo TB-9707F + Android 13)
  • iOS: not observed

Stack trace (minified UMD + symbolicated)

Top frames:

TypeError: Cannot read property 'Closing' of undefined
  at anonymous       (app:///index.android.bundle:1:1875)
  at tryCallOne      (app:///index.android.bundle:1:1181)
  at a               (livekit-client.umd.js:1:84844)   <-- generic awaiter helper "a"
  at throw           (native)
  at Qi$argument_3   (livekit-client.umd.js:1:299372)  <-- RTCEngine.negotiate TypedPromise executor

Minified context at the throw site (frame 4):

}finally{this.off(e.EngineEvent.Closing,s),this.off(e.EngineEvent.Restarting,s)}

Source-mapped to src/room/RTCEngine.ts @ v2.17.2, lines 1562-1565:

} finally {
  this.off(EngineEvent.Closing, handleClosed);
  this.off(EngineEvent.Restarting, handleClosed);
}

Where e is the bundled module-namespace object (UMD output for import * as e from './events') and EngineEvent is the TS enum exported from that module.

Sentry metadata

  • mechanism.type: onunhandledrejection
  • mechanism.handled: true
  • tags.hermes: true
  • tags.os: Android 13
  • All 715 events match this exact 5-frame stack — same minified Qi$argument_3 site every time.

What appears to be happening

For e.EngineEvent.Closing to throw "Cannot read property 'Closing' of undefined" the e.EngineEvent access itself returns undefined, then .Closing is read off that.

We don't believe the entire UMD namespace e is undefined — RTCEngine.negotiate() would have failed much earlier in that case. Our best guess: the EngineEvent enum on the namespace e is missing at this point in execution. Possibilities:

  1. Hermes module-namespace lazy proxy — Hermes's UMD-as-module shim materializes the namespace lazily and an enum re-export drops in some edge case.
  2. Race / TDZ on tree-shaken re-export — the enum is re-exported from src/room/events.ts and the UMD output threads it through several variable assignments; on certain reject timings the binding isn't yet visible to the catch/finally closure.
  3. Sentry React Native SDK instrumentation — Sentry wraps RN's Promise to capture unhandled rejections; the wrapper might run cleanup against a partially-deinitialized closure scope on a hot-reload or bundle-eviction path.

We can't reproduce on demand — it's intermittent, low-spec-Android only, and only fires when negotiate() rejects (e.g. ICE candidate exhaustion, signal disconnect mid-negotiate).

Related upstream history

Workaround applied on our side

Sentry beforeSend filter that drops events matching the exact 4-clause signature (TypeError + exact message + livekit-client.umd.js frame + onunhandledrejection mechanism). This stops the noise in our Sentry but doesn't fix anything for users — the negotiation reconnect path is still silently broken whenever this fires.

Ask

  1. Confirmation whether this is a known UMD-on-Hermes pattern.
  2. If yes, can the negotiate() finally-block defensively guard against EngineEvent being undefined (e.g. capture EngineEvent.Closing / EngineEvent.Restarting into local consts at function entry, before the await)? That would make the finally block robust regardless of namespace resolution timing.
  3. Tracking issue we can subscribe to so we know when to remove our beforeSend filter.

Happy to provide more event data, runtime tags, or a sanitized stack export if useful.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions