Skip to content

ChatClient schedules one macrotask per live stream chunk and accumulates seconds of processing latency #1193

Description

@kolaworld

TanStack AI version

0.47.3

Framework/Library version

@tanstack/ai-client: 0.25.2 | @tanstack/ai-svelte: 0.18.1

Describe the bug and the steps to reproduce it

The live subscription loop awaits processIncomingChunk() for every stream chunk.

After processing a chunk, processIncomingChunk() awaits setTimeout(resolve, 0) whenever deferred processing is enabled and the document is visible. This gives the UI an opportunity to paint, but it also schedules one macrotask for every reasoning, text, tool-input and lifecycle chunk.

The timer was introduced in commit 4ab149fd to let the UI paint between live chunks. PR #1012 later skipped it for hidden documents after background timer clamping reduced draining to approximately one chunk per second. Visible documents still pay the timer cost for every chunk, independently of whether that chunk publishes a UI update.

On streams with thousands of small chunks, timer scheduling becomes the dominant processing cost and the subscription consumer falls seconds behind an already-completed transport.

In one observed run, five streams contained 1,368–2,595 chunks each. Their transports completed in 2.98–4.12 seconds, while append() completed another 0.18–4.09 seconds later; the processing tails totaled 13.15 seconds even though chunk receipt lag remained between 2 and 64 milliseconds.

This behavior is independent of persistence. Live streams use the deferred path whether or not a persistor is configured. Persisted replay explicitly passes defer: false, so replay does not incur the per-chunk timer cost.

Expected behavior

Live chunks should be processed immediately and in order. UI-facing text publication should continue to use the existing configurable ChunkStrategy, while terminal, interrupt and error state remains immediate.

The subscription should not schedule one timer per chunk. If draining a buffered backlog would otherwise create a long task, it can process a bounded slice and use the existing setTimeout(resolve, 0) once between slices. This does not require a new scheduling primitive or public scheduler option.

Your Minimal, Reproducible Example - (Sandbox Highly Recommended)

https://stackblitz.com/edit/vitejs-vite-drnv6eeo?file=package.json,index.html,src%2Fmain.ts

Screenshots or Videos (Optional)

No response

Do you intend to try to help solve this bug with your own PR?

Yes, I am also opening a PR that solves the problem along side this issue

Terms & Code of Conduct

  • I agree to follow this project's Code of Conduct
  • I understand that if my bug cannot be reliable reproduced in a debuggable environment, it will probably not be fixed and this issue may even be closed.

Metadata

Metadata

Assignees

No one assigned

    Labels

    has-prAn open PR references this issuewaiting-on: maintainerThe ball is in the maintainers’ court

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions