Environment
- SDK:
client-sdk-swift 2.16.0 (latest released version as of 2026-09-01)
- Server:
livekit-server --dev v1.13.6 (local dev server)
- Test device: iOS Simulator (an iPhone-16-class simulator runtime), not a
physical device
- Verification method for this report: source read of the
2.16.0 tag on GitHub
(Sources/LiveKit/Core/Transport.swift, Sources/LiveKit/Participant/LocalParticipant.swift).
The 200-cycle memory measurement below was captured against the same 2.16.0 build
in an earlier test run; the source read confirms the code path is unchanged in the
current 2.16.0 tag.
What happens
In an app that repeatedly publishes and unpublishes a microphone audio track, process
memory (phys_footprint) grows steadily across cycles and does not plateau.
Measurement
200 unpublish/re-publish cycles, phys_footprint sampled at intervals:
| Cycle |
phys_footprint |
| baseline |
26.58 MB |
| 50 |
30.13 MB |
| 100 |
31.53 MB |
| 150 |
32.92 MB |
| 199 |
34.53 MB |
Net growth: 26.58 → 34.53 MB over 200 cycles, with per-block growth of roughly
28–33 KB/cycle after the first block (the first 50 cycles grew faster, ~61 KB/cycle,
which we attribute to one-time warm-up allocations rather than the leak itself).
Growth does not plateau across the full 200-cycle run.
Note: phys_footprint is whole-process resident memory (Swift/ObjC heap + native
WebRTC + stacks, etc.), not an isolated "native heap" counter, so this number is not
directly comparable in magnitude to a native-heap-only measurement taken on another
platform/SDK — only the "does it plateau or not" shape is comparable across platforms.
We also tried periodically tearing down and recreating the room connection every 50
cycles as a mitigation. It reduced (and in one interval nearly eliminated) the growth
within a block, but results were inconsistent block-to-block — one block recovered
almost nothing while another was essentially flat — consistent with the transceiver
stop/release being asynchronous and the mitigation's effectiveness depending on how
much wall-clock time elapses before the next connection. We did not verify this timing
explanation independently; it is our best-effort read of the behavior, not a confirmed
mechanism.
Expected vs actual
- Expected: unpublishing an audio track stops/releases its
RTCRtpTransceiver
(freeing native resources), the same way it does for video tracks.
- Actual: the transceiver is left attached to the peer connection indefinitely.
Every publish adds one more, permanently, for the lifetime of the connection.
Suspected source location
Sources/LiveKit/Core/Transport.swift:
func remove(track sender: LKRTCRtpSender) throws {
guard _pc.removeTrack(sender) else {
throw LiveKitError(.webRTC, message: "Failed to remove track")
}
releaseTransceiver(sender: sender)
}
// Try to stop the transceiver and free the resources
// Workaround: https://groups.google.com/g/discuss-webrtc/c/WDsGuVucBjQ?pli=1
private func releaseTransceiver(sender: LKRTCRtpSender) {
if let transceiver = _pc.transceivers.first(where: { $0.sender == sender }),
transceiver.mediaType == .video, !transceiver.isStopped
{
log("Stopping video transceiver", .debug)
transceiver.stopInternal()
}
}
The transceiver.mediaType == .video condition means stopInternal() is only ever
called for video transceivers. Audio transceivers reaching this function always skip
the stop/release step and remain attached to the peer connection.
On the publish side, LocalParticipant.swift calls
publisher.addTransceiver(with: track.mediaTrack, transceiverInit: transInit) fresh on
every publish, with no lookup/reuse of a previously-created (and now orphaned)
transceiver — so the two sides compound: a new transceiver every publish, none of them
released for audio on unpublish.
This looks like the audio-side counterpart of #420 ("Huge memory leaks produced by publishing/unpublishing camera tracks", now closed)
for camera/video tracks (publish/unpublish memory leak on video, closed after adding
the releaseTransceiver workaround referenced by the WebRTC discussion-group link in
the comment above) — the fix appears to have been scoped to video only, leaving audio
with the same underlying behavior.
Repro
let track = LocalAudioTrack.createTrack()
for _ in 0..<200 {
let publication = try await localParticipant.publish(audioTrack: track)
// ... hold briefly ...
try await localParticipant.unpublish(publication: publication)
}
// phys_footprint sampled periodically shows steady growth, no plateau.
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, growing resident memory over the life of a long-running session.
Suspected fix
Drop the transceiver.mediaType == .video condition in releaseTransceiver (or add
an equivalent audio branch) so that audio transceivers are stopped the same way video
ones are on unpublish, mirroring whatever safety consideration originally limited the
video-only fix (if there was a reason audio was excluded, it isn't stated in the
comment).
Workaround
None found from application code — periodic full room reconnects reduce, but do not
reliably bound, the growth (see measurement notes above).
Question for maintainers
Was audio deliberately excluded from releaseTransceiver for a specific reason (e.g.
some interaction with audio session lifecycle), or was the video-only condition simply
never extended to audio when the video leak was fixed?
Companion report for the Android SDK, same defect shape: livekit/client-sdk-android#1012
Environment
client-sdk-swift2.16.0 (latest released version as of 2026-09-01)livekit-server --devv1.13.6 (local dev server)physical device
2.16.0tag on GitHub(
Sources/LiveKit/Core/Transport.swift,Sources/LiveKit/Participant/LocalParticipant.swift).The 200-cycle memory measurement below was captured against the same
2.16.0buildin an earlier test run; the source read confirms the code path is unchanged in the
current
2.16.0tag.What happens
In an app that repeatedly publishes and unpublishes a microphone audio track, process
memory (
phys_footprint) grows steadily across cycles and does not plateau.Measurement
200 unpublish/re-publish cycles,
phys_footprintsampled at intervals:Net growth: 26.58 → 34.53 MB over 200 cycles, with per-block growth of roughly
28–33 KB/cycle after the first block (the first 50 cycles grew faster, ~61 KB/cycle,
which we attribute to one-time warm-up allocations rather than the leak itself).
Growth does not plateau across the full 200-cycle run.
Note:
phys_footprintis whole-process resident memory (Swift/ObjC heap + nativeWebRTC + stacks, etc.), not an isolated "native heap" counter, so this number is not
directly comparable in magnitude to a native-heap-only measurement taken on another
platform/SDK — only the "does it plateau or not" shape is comparable across platforms.
We also tried periodically tearing down and recreating the room connection every 50
cycles as a mitigation. It reduced (and in one interval nearly eliminated) the growth
within a block, but results were inconsistent block-to-block — one block recovered
almost nothing while another was essentially flat — consistent with the transceiver
stop/release being asynchronous and the mitigation's effectiveness depending on how
much wall-clock time elapses before the next connection. We did not verify this timing
explanation independently; it is our best-effort read of the behavior, not a confirmed
mechanism.
Expected vs actual
RTCRtpTransceiver(freeing native resources), the same way it does for video tracks.
Every publish adds one more, permanently, for the lifetime of the connection.
Suspected source location
Sources/LiveKit/Core/Transport.swift:The
transceiver.mediaType == .videocondition meansstopInternal()is only evercalled for video transceivers. Audio transceivers reaching this function always skip
the stop/release step and remain attached to the peer connection.
On the publish side,
LocalParticipant.swiftcallspublisher.addTransceiver(with: track.mediaTrack, transceiverInit: transInit)fresh onevery publish, with no lookup/reuse of a previously-created (and now orphaned)
transceiver — so the two sides compound: a new transceiver every publish, none of them
released for audio on unpublish.
This looks like the audio-side counterpart of #420 ("Huge memory leaks produced by publishing/unpublishing camera tracks", now closed)
for camera/video tracks (publish/unpublish memory leak on video, closed after adding
the
releaseTransceiverworkaround referenced by the WebRTC discussion-group link inthe comment above) — the fix appears to have been scoped to video only, leaving audio
with the same underlying behavior.
Repro
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, growing resident memory over the life of a long-running session.
Suspected fix
Drop the
transceiver.mediaType == .videocondition inreleaseTransceiver(or addan equivalent audio branch) so that audio transceivers are stopped the same way video
ones are on unpublish, mirroring whatever safety consideration originally limited the
video-only fix (if there was a reason audio was excluded, it isn't stated in the
comment).
Workaround
None found from application code — periodic full room reconnects reduce, but do not
reliably bound, the growth (see measurement notes above).
Question for maintainers
Was audio deliberately excluded from
releaseTransceiverfor a specific reason (e.g.some interaction with audio session lifecycle), or was the video-only condition simply
never extended to audio when the video leak was fixed?
Companion report for the Android SDK, same defect shape: livekit/client-sdk-android#1012