Skip to content

[Android] Audio RtpTransceivers are never released on unpublishTrack, leaking native memory across publish/unpublish cycles #1012

Description

@hiropo

Environment

  • SDK: io.livekit:livekit-android:2.28.1 (latest released version as of 2026-09-01)
  • Server: livekit-server --dev v1.13.6 (local dev server)
  • Test device: Android Emulator, API 36 (not a physical device)
  • Verification method for this report: source read of the 2.28.1 tag on GitHub
    (RTCEngine.kt / LocalParticipant.kt). The 200-cycle memory measurement below was
    captured against the same 2.28.1 build in an earlier test run; it has not been
    re-run against this exact tag checkout, but the source is unchanged between the
    measured build and 2.28.1, so the code path described is the one that produced
    the measurement.

What happens

In an app that repeatedly publishes and unpublishes a microphone audio track (e.g. a
push-to-talk pattern: publish on key-down, unpublish on key-up), the native heap grows
linearly and never plateaus.

Measurement

200 publish/unpublish cycles, native heap sampled every 10 cycles via
Debug.getNativeHeapAllocatedSize():

Value
Baseline (cycle 0) 27.4 MB
End (cycle 200) 69.3 MB
Net growth +41.9 MB
Per-cycle growth ~204 KB/cycle
Trend Linear, no plateau observed over 200 cycles

The JVM heap stays flat across the same run, so this is not a Java-side reference
leak — it is native (WebRTC-layer) memory that is never released.

Expected vs actual

  • Expected: unpublishing an audio track releases the RtpTransceiver (or otherwise
    frees the native resources) it was assigned on publish, the same way it does for
    video tracks.
  • Actual: the transceiver is left attached to the peer connection forever. Every
    publish call adds one more, permanently, for the lifetime of the connection.

Suspected source location

  • RTCEngine.kt, createSenderTransceiver(...): called on every publish, calls
    PeerConnection.addTransceiver() unconditionally. There is no lookup/reuse of an
    existing transceiver.

  • LocalParticipant.kt, unpublishTrack(track, stopOnUnpublish): after
    engine.removeTrack(track.rtcTrack), the cleanup block that actually stops the
    transceiver and frees native resources is:

    // Each publish creates a new transceiver, plus one per backup codec. Removing the
    // track from its sender doesn't release them, so they would otherwise be retained
    // until the connection closes. Stopping them releases the native resources and frees
    // the SDP m-sections for reuse. Limited to video, where this leak is significant.
    if (track is LocalVideoTrack) {
        engine.stopTransceivers(listOfNotNull(track.transceiver) + track.simulcastTransceivers)
        track.transceiver = null
        track.clearSimulcastCodecs()
    }

    The comment above this block explicitly states the same retention problem applies
    generally ("Removing the track from its sender doesn't release them"), but the
    if (track is LocalVideoTrack) guard means the stop/cleanup only runs for video.
    Audio tracks fall through with no equivalent path.

This looks like the audio-side counterpart of #521 ("Memory leaks produced by publishing/unpublishing camera tracks", now closed)
for camera/video tracks (publish/unpublish memory leak on video, closed after adding
the stopTransceivers cleanup) — the fix appears to have been scoped to video only,
leaving audio with the same underlying behavior.

Repro

room.connect(url, token)
repeat(200) {
    val track = localParticipant.createAudioTrack()
    localParticipant.publishAudioTrack(track)
    // ... hold briefly ...
    localParticipant.unpublishTrack(track, stopOnUnpublish = false)
}
// Debug.getNativeHeapAllocatedSize() sampled every 10 iterations shows linear growth,
// no plateau.

Behavior is the same with stopOnUnpublish = true.

Impact

Any push-to-talk / walkie-talkie style app that does frequent publish/unpublish
cycles (hundreds per session is a realistic usage pattern) accumulates transceivers
without bound. Besides the raw memory growth, the SDP also keeps growing (one more
m-section per cycle), which makes renegotiation progressively more expensive over a
long-running session.

Suspected fix

Either:

  1. Drop the is LocalVideoTrack guard in unpublishTrack and stop/release the
    transceiver for audio tracks too (mirroring the existing video path), or
  2. Reuse an existing stopped/idle transceiver on the next publishAudioTrack call
    instead of always calling addTransceiver().

Workaround

None available from application code: the only accessor to the track's transceiver
(LocalAudioTrack.getTransceiver$livekit_android_sdk_release()) is Kotlin internal
and not reachable outside the SDK module. The only mitigation we found is periodically
tearing down and reconnecting the room (which resets the peer connection), which
reduces the growth rate but does not eliminate it — see companion note in our internal
testing: a full room reconnect every 50 cycles cut per-cycle growth by roughly a third
but the "floor" after each reconnect still climbed cycle over cycle.

Question for maintainers

Is transceiver reuse (or an audio-track equivalent of the existing video cleanup path)
feasible? Alternatively, is there a supported way to release an audio transceiver from
application code today that we're missing?


Companion report for the iOS SDK, same defect shape: livekit/client-sdk-swift#1104

Related (Android): #1013 — the workaround for this leak (reusing one track) hits a ~20s republish hang.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions