Skip to content

fix(sdk): StreamController retains every event when nothing iterates #477

Description

@EricAndrechek

Area: sdk — streaming. Pre-existing on main; surfaced reviewing #470 and deliberately not fixed there.

StreamController retains every event it has ever received unless something is consuming it through the async iterator. A long-lived .subscribe()-only stream — the pattern our own docs lead with — grows without bound until the process dies or the stream is closed.

Mechanism

clients/ts/src/stream/controller.ts:31-37, in the onEvent handler:

const waiter = this._waiters.shift();
if (waiter) {
  waiter.resolve({ value: event, done: false });
} else {
  this._buffer.push(event);          // :36
}

_buffer has exactly four references in the file (:20 declaration, :36 push, :170-171 read/shift). The only drain is the async iterator's next(). A consumer who calls .subscribe() and never writes for await never pushes a waiter and never calls next(), so the else branch is taken on every single event and nothing ever removes it. There is no cap.

Callback delivery still works — the fan-out at :28-30 happens first — so the stream behaves correctly and leaks silently.

Why it matters

.subscribe() is the primary documented API: it is the first example under Streaming in docs/src/content/docs/sdk/streaming.md, and liveQuery() is built on it. The buffer exists to hand events to a for await consumer that hasn't asked yet; for a callback consumer it is pure retention.

Rough cost: a modest 100 events/sec stream at ~200 bytes/event retains ~70 MB/hour, unbounded. It is the _buffer reference itself that pins the events, so nothing is collectable while the controller lives.

Retention is doubled for a liveQuery() or a .where()-filtered stream. FilteredStreamController extends StreamController (query-builder.ts), so the inner controller buffers every event off the wire while the outer buffers every event that matches the filter. Both layers are subject to the same never-drained path.

Fix sketch

The buffer should only accumulate when someone is actually iterating. Options, roughly in order of preference:

  1. Only buffer once the iterator has been requested. Set a flag in [Symbol.asyncIterator]() and make :36 conditional on it. Preserves the current guarantee for for await consumers (including one that attaches late but before events flow) and reduces a subscribe-only stream to zero retention.
  2. Cap it with a documented bound and drop-oldest, which turns an unbounded leak into a bounded one but silently loses events for a slow iterator.
  3. Drop the buffer and have next() only ever resolve from a waiter — simplest, but a for await consumer that awaits something between iterations would miss events, which is a real regression.

(1) is the only one that is purely a fix. Worth checking against #389, which touches this same buffering.

Acceptance

  • A .subscribe()-only stream retains no events regardless of how many arrive
  • A for await consumer still receives events that arrived between iterations
  • A consumer that attaches an iterator after subscribing does not lose events that arrived in between, or the change in that behaviour is documented
  • Pinned by a test asserting the internal buffer stays empty across N events with only a callback subscriber — use a filtered stream, so the assertion covers both the inner and outer controller

Related: #473 (subscriber-throw isolation, same loops — noticed while verifying that the throw-path buffer growth described there is the smaller of two), #389 (StreamController buffering), #470 (documents the throw-path case; does not change this).

Activity

  1. coderabbitai commented on Aug 13, 2026

    @coderabbitai
    ⚠️ Possible Duplicate Issue(s)
    🔗 Related PRs

    #470 - feat(sdk)!: stream over fetch so SSE authenticates by header [open]


    🧪 Issue enrichment is currently in open beta.

    You can configure auto-planning by selecting labels in the issue_enrichment configuration.

    To disable automatic issue enrichment, add the following to your .coderabbit.yaml:

    issue_enrichment:
      auto_enrich:
        enabled: false

    💬 Have feedback or questions? Drop into our discord!

  2. EricAndrechek commented on Aug 25, 2026

    @EricAndrechek
    MemberAuthor

    Duplicate of #389.

    Same bug, same file, same mechanism (clients/ts/src/stream/controller.ts — _buffer.push in the else branch of onEvent, drained only by the async iterator). #389 was filed first, from the repo-wide audit, and is the superset: it also covers close() not clearing _buffer and FilteredStreamController double-buffering, neither of which is in this issue's scope section.

    Everything unique here has been ported to #389 in this comment — the ~70 MB/hour retention estimate and the pinning explanation, the _buffer four-reference proof, the three ranked fix options (and why only "buffer once the iterator is requested" is purely a fix), the acceptance checklist, and the #473 / #470 cross-links. Nothing is lost by closing this.

    Closing as a duplicate — continue in #389.


    Deduplicated by the pm-triage routine on 2026-08-25, on Eric's instruction.

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

    area/sdkTypeScript SDK (clients/ts/)bugSomething isn't working

    Type

    No type

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions