Describe the bug
inference.STT sessions show unbounded, near-linear memory growth for the entire lifetime of a stream, even when the subscribed audio track carries no real speech (silence only). Growth continues indefinitely — no plateau — until the underlying connection is torn down.
Root cause, confirmed via heap snapshot analysis with allocation stack traces enabled: in packages/agents/src/inference/stt.ts, the send() function's per-frame loop (compiled to dist/inference/stt.js:414) does:
const result = await ThrowsPromise.race([iterator.next(), abortPromise]);
abortPromise is created once per connection attempt and only settles when the connection is aborted. Promise.race() (which ThrowsPromise.race delegates to directly) attaches a .then() reaction to every candidate promise, including the one that loses the race — and it never detaches that reaction. Since abortPromise stays pending for the whole connection lifetime, every single loop iteration (i.e. every incoming audio frame) permanently attaches a new PromiseReaction to it. None of these are released until the connection itself closes, so every frame's resolved { value, done } result — and transitively the AudioFrame/Int16Array/ArrayBuffer it holds — stays reachable for the entire stream's lifetime.
This is the exact same anti-pattern already fixed in the Deepgram plugin (#1950 / PR #1957) and reported for readStream() in agents/src/utils.ts (#2046). Neither of those covers this occurrence — inference/stt.ts's own send() calls Promise.race directly and never goes through readStream(), confirmed by the captured allocation stack (send @ inference/stt.js:414 → race → ThrowsPromise, no readStream frame anywhere in the chain).
Heap snapshot instance counts (~12.5-minute session, silent audio track, STT only, LLM/TTS disabled):
47,828 ThrowsPromise (2 per frame — matches Promise.race([a, b]) exactly)
23,895 AudioFrame
Retainer trace confirms the chain: AudioFrame → {value,done} result object → (Throws)Promise → Context/closure → PromiseReaction.
Relevant log output
process.memoryUsage() sampled every 60s over ~59 minutes, single subscribed (silent) audio track, no reconnects observed:
t=0min heapUsed=55.1MB heapTotal=62.3MB external=10.1MB arrayBuffers=8.2MB
t=59min heapUsed=145.2MB heapTotal=172.0MB external=118.0MB arrayBuffers=8.2MB
Note arrayBuffers stays flat while external grows ~108MB — the leak is in Promise/reaction bookkeeping, not raw audio buffer accumulation (which would show up in arrayBuffers).
Describe your environment
System:
OS: macOS 26.5.2
CPU: (8) arm64 Apple M3
Binaries:
Node: 25.6.1
npm: 11.9.0
pnpm: 10.33.0
npmPackages:
@livekit/agents: 1.5.2
@livekit/rtc-node: 0.13.31
I also observed the same unbounded memory growth (~270 MB/h) in production on LiveKit Cloud running @livekit/agents@1.2.7, prior to this deeper investigation — I did not perform root-cause analysis at that version, so I can't confirm the exact mechanism was identical, but the symptom (linear, non-plateauing growth tied to STT session duration) matches.
Minimal reproducible example
- Create an inference.STT instance and call .stream().
- Subscribe to (or synthetically feed) a continuous audio track — silence is sufficient, no real speech required.
- Keep the stream open for 30–60+ minutes without closing/reconnecting.
- Log process.memoryUsage() on an interval.
heapUsed/heapTotal/external grow near-linearly with no plateau. arrayBuffers stays flat, ruling out raw audio-buffer retention as the cause.
Additional information
Diagnosed via Chrome DevTools "Allocation instrumentation on timeline" with "Record stack traces" enabled, then parsed programmatically (Node script reading the raw .heaptimeline JSON — nodes/edges/trace_tree/trace_function_infos) to get exact allocation-site call stacks and retainer chains, independent of DevTools' UI. Happy to share the analysis scripts or raw counts if helpful.
Describe the bug
inference.STT sessions show unbounded, near-linear memory growth for the entire lifetime of a stream, even when the subscribed audio track carries no real speech (silence only). Growth continues indefinitely — no plateau — until the underlying connection is torn down.
Root cause, confirmed via heap snapshot analysis with allocation stack traces enabled: in packages/agents/src/inference/stt.ts, the send() function's per-frame loop (compiled to dist/inference/stt.js:414) does:
const result = await ThrowsPromise.race([iterator.next(), abortPromise]);
abortPromise is created once per connection attempt and only settles when the connection is aborted. Promise.race() (which ThrowsPromise.race delegates to directly) attaches a .then() reaction to every candidate promise, including the one that loses the race — and it never detaches that reaction. Since abortPromise stays pending for the whole connection lifetime, every single loop iteration (i.e. every incoming audio frame) permanently attaches a new PromiseReaction to it. None of these are released until the connection itself closes, so every frame's resolved { value, done } result — and transitively the AudioFrame/Int16Array/ArrayBuffer it holds — stays reachable for the entire stream's lifetime.
This is the exact same anti-pattern already fixed in the Deepgram plugin (#1950 / PR #1957) and reported for readStream() in agents/src/utils.ts (#2046). Neither of those covers this occurrence — inference/stt.ts's own send() calls Promise.race directly and never goes through readStream(), confirmed by the captured allocation stack (send @ inference/stt.js:414 → race → ThrowsPromise, no readStream frame anywhere in the chain).
Heap snapshot instance counts (~12.5-minute session, silent audio track, STT only, LLM/TTS disabled):
47,828 ThrowsPromise (2 per frame — matches Promise.race([a, b]) exactly)
23,895 AudioFrame
Retainer trace confirms the chain: AudioFrame → {value,done} result object → (Throws)Promise → Context/closure → PromiseReaction.
Relevant log output
process.memoryUsage() sampled every 60s over ~59 minutes, single subscribed (silent) audio track, no reconnects observed:
t=0min heapUsed=55.1MB heapTotal=62.3MB external=10.1MB arrayBuffers=8.2MB
t=59min heapUsed=145.2MB heapTotal=172.0MB external=118.0MB arrayBuffers=8.2MB
Note arrayBuffers stays flat while external grows ~108MB — the leak is in Promise/reaction bookkeeping, not raw audio buffer accumulation (which would show up in arrayBuffers).
Describe your environment
System:
OS: macOS 26.5.2
CPU: (8) arm64 Apple M3
Binaries:
Node: 25.6.1
npm: 11.9.0
pnpm: 10.33.0
npmPackages:
@livekit/agents: 1.5.2
@livekit/rtc-node: 0.13.31
I also observed the same unbounded memory growth (~270 MB/h) in production on LiveKit Cloud running @livekit/agents@1.2.7, prior to this deeper investigation — I did not perform root-cause analysis at that version, so I can't confirm the exact mechanism was identical, but the symptom (linear, non-plateauing growth tied to STT session duration) matches.
Minimal reproducible example
heapUsed/heapTotal/external grow near-linearly with no plateau. arrayBuffers stays flat, ruling out raw audio-buffer retention as the cause.
Additional information
Diagnosed via Chrome DevTools "Allocation instrumentation on timeline" with "Record stack traces" enabled, then parsed programmatically (Node script reading the raw .heaptimeline JSON — nodes/edges/trace_tree/trace_function_infos) to get exact allocation-site call stacks and retainer chains, independent of DevTools' UI. Happy to share the analysis scripts or raw counts if helpful.