Skip to content

Memory leak in inference.STT: send()'s Promise.race([iterator.next(), abortPromise]) retains every audio frame for the stream's lifetime (same pattern as #1950/#1957, #2046) #2053

Description

@antoine-navette

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

  1. Create an inference.STT instance and call .stream().
  2. Subscribe to (or synthetically feed) a continuous audio track — silence is sufficient, no real speech required.
  3. Keep the stream open for 30–60+ minutes without closing/reconnecting.
  4. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Fields

    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