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:
- Drop the
is LocalVideoTrack guard in unpublishTrack and stop/release the
transceiver for audio tracks too (mirroring the existing video path), or
- 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.
Environment
io.livekit:livekit-android:2.28.1(latest released version as of 2026-09-01)livekit-server --devv1.13.6 (local dev server)2.28.1tag on GitHub(
RTCEngine.kt/LocalParticipant.kt). The 200-cycle memory measurement below wascaptured against the same
2.28.1build in an earlier test run; it has not beenre-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 producedthe 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():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
RtpTransceiver(or otherwisefrees the native resources) it was assigned on publish, the same way it does for
video tracks.
publish call adds one more, permanently, for the lifetime of the connection.
Suspected source location
RTCEngine.kt,createSenderTransceiver(...): called on every publish, callsPeerConnection.addTransceiver()unconditionally. There is no lookup/reuse of anexisting transceiver.
LocalParticipant.kt,unpublishTrack(track, stopOnUnpublish): afterengine.removeTrack(track.rtcTrack), the cleanup block that actually stops thetransceiver and frees native resources is:
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
stopTransceiverscleanup) — the fix appears to have been scoped to video only,leaving audio with the same underlying behavior.
Repro
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:
is LocalVideoTrackguard inunpublishTrackand stop/release thetransceiver for audio tracks too (mirroring the existing video path), or
publishAudioTrackcallinstead 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 Kotlininternaland 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.