Skip to content

feat(chat): throttle chat UI updates by default - #2058

Open
ben-reitz wants to merge 1 commit into
fix/replay-burst-update-depthfrom
fix/default-chat-throttle
Open

feat(chat): throttle chat UI updates by default#2058
ben-reitz wants to merge 1 commit into
fix/replay-burst-update-depthfrom
fix/default-chat-throttle

Conversation

@ben-reitz

@ben-reitz ben-reitz commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

A proposal, stacked on #2050. Fixes #1913 together with that PR.

Why a second change

#2050 stops a resumed stream from writing chat state once per replayed chunk. It merges the chunks that belong to the same piece of text, so a few hundred chunks become a handful of writes.

That helps enormously, but it only merges within a part. A tool step costs about seven writes on its own, so a replayed turn of more than about seven tool steps still reaches React's 50-render limit and still shows a false error. Measured with the merge in place: six steps fine, eight throws.

A throttle does not care how many chunks arrive, so it covers what merging cannot.

flowchart LR
  C["chunks arrive"] --> M["#2050<br/>merge replayed chunks"] --> T["this PR<br/>throttle renders"] --> R["at most one render<br/>per 50ms"]
Loading

The change

useAgentChat now coalesces chat updates every 50ms. Pass throttle: false to render every chunk as it arrives, or a number to pick your own interval.

Nothing else changes, and the first chunk of a stream is never delayed - the AI SDK throttles with throttleit, which runs the first call of an idle window immediately.

Why 50ms

Any value above zero prevents the crash, because the update then arrives from a timer rather than from the current task. Verified at 1, 4, 16, 50, 100 and 250ms. So the size of the number is a cost decision rather than a safety one. Measured over a 200-chunk turn at ~100 chunks/sec (caveat that this is obvs on a powerful macbook):

throttle renders render time
off 404 138ms
16ms 261 89ms
50ms 90 37ms
100ms 48 21ms
250ms 22 9ms

50ms removes 78% of the renders. Larger values save progressively less while letting the text lag further behind the stream. It is also the value the AI SDK's own documentation uses, and 25 of our 27 examples already pass 100ms by hand, so throttling is not new behaviour for our own UIs - only the default is.

Two things worth knowing

@ai-sdk/react v3 reads only experimental_throttle; v4 renamed it to throttle. Both majors are in our peer range, so the value is sent under both names. Turning it off omits both names rather than sending 0, because both majors decide with waitMs != null, and only an omitted option reaches their unthrottled path.

This proposed change provides a mitigation rather than a guarantee. useChat throttles its store subscription, but its getSnapshot returns a new messages array on every chunk, and React forces a synchronous re-render whenever that identity moved during a render - a path the throttle never sees. That is vercel/ai#6166, with a fix open in vercel/ai#17893. Writing state less often is the only thing that bounds it, which is why #2050 does the heavier lifting.

Tests

Eight unit tests in chat/__tests__/chat-throttle.test.ts cover the precedence rules and both spellings. Two tests in react-tests/default-throttle.test.tsx drive the real hook with a 12-step tool turn: it stays healthy on defaults, and throttle: false restores the old failure, which proves the caller's value reaches the SDK.

Suites: agents chat 540 · react 135 · ai-chat 737 · think 920 + react 5 · pnpm run check.

@changeset-bot

changeset-bot Bot commented Aug 6, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: f8be3ad

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
agents Minor
@cloudflare/agent-think Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@ben-reitz
ben-reitz force-pushed the fix/default-chat-throttle branch from 1fdc907 to 60edd64 Compare August 6, 2026 14:06
@ben-reitz
ben-reitz marked this pull request as ready for review August 6, 2026 14:10

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no potential bugs to report.

View in Devin Review to see 1 additional finding.

Open in Devin Review

@pkg-pr-new

pkg-pr-new Bot commented Aug 6, 2026

Copy link
Copy Markdown

Open in StackBlitz

agents

npm i https://pkg.pr.new/agents@2058

@cloudflare/ai-chat

npm i https://pkg.pr.new/@cloudflare/ai-chat@2058

@cloudflare/codemode

npm i https://pkg.pr.new/@cloudflare/codemode@2058

create-think

npm i https://pkg.pr.new/create-think@2058

hono-agents

npm i https://pkg.pr.new/hono-agents@2058

@cloudflare/shell

npm i https://pkg.pr.new/@cloudflare/shell@2058

@cloudflare/think

npm i https://pkg.pr.new/@cloudflare/think@2058

@cloudflare/voice

npm i https://pkg.pr.new/@cloudflare/voice@2058

@cloudflare/worker-bundler

npm i https://pkg.pr.new/@cloudflare/worker-bundler@2058

commit: f8be3ad

gr2m added a commit to vercel/ai that referenced this pull request Aug 6, 2026
## Background

`useChat` currently throttles its messages subscription callback, but
its `useSyncExternalStore` snapshot always reads the latest
`chat.messages` array. Because streaming replaces that array for every
chunk, any unrelated React render can observe a new snapshot before the
throttled callback publishes it. In high-frequency streams this bypasses
`throttle`, causes per-chunk renders, and can contribute to the "Maximum
update depth exceeded" failures reported in #6166.

## Summary

- Keep a published messages snapshot per `useChat` hook and advance it
from that hook's throttled subscription callback.
- Publish the latest message snapshot before `ready` or `error` becomes
observable, including normal completion and aborts.
- Synchronize updates that occur between render and subscription, and
ignore delayed callbacks after a hook unsubscribes or changes chat
instances.
- Add regression coverage for unrelated renders, terminal status/message
coherence, aborts, errors, and delayed callbacks after chat replacement.
- Turn the existing Next.js throttle route into a deterministic
end-to-end reproduction that streams 500 chunks while forcing unrelated
renders, reports pass/fail from observed snapshot identities, and
verifies the complete message is visible when status becomes `ready`.
- Add a patch changeset for `@ai-sdk/react`.

## Contributor Credit

- @brahmveda-arkin reported #6166.
- @takumiz19 isolated the snapshot/subscription mismatch and proposed
#17893.
- @ben-reitz demonstrated the practical value of a conservative UI
update cadence in cloudflare/agents#2058.

## End-to-End Verification

Ran `/chat/throttle` in `examples/ai-e2e-next` in a real browser. The
route streams 500 chunks (1,000 assistant characters) with `throttle:
50` while a zero-delay timer independently re-renders the component.

- Before: **FAIL**, 255 distinct message snapshots in 1,475ms (maximum
expected: 34), across 946 total React renders.
- After: **PASS**, 15 distinct message snapshots in 1,144ms (maximum
expected: 27), across 602 total React renders.

The patched run rendered all 1,000 assistant characters on the same
render where status became `ready`, and had no Next.js error overlay or
browser error.

## Checklist

- [x] All commits are signed (PRs with unsigned commits cannot be
merged)
- [x] Tests have been added / updated (for bug fixes / features)
- [ ] Documentation has been added / updated (for bug fixes / features)
- [x] A _patch_ changeset for relevant packages has been added (for bug
fixes / features - run `pnpm changeset` in the project root)
- [x] I have reviewed this pull request (self-review)

## Future Work

This PR intentionally leaves the current opt-in default unchanged, so it
does not protect applications that omit `throttle` from high-frequency
unthrottled rendering.

For v8, I recommend making a 50ms UI publication cadence the default
when `throttle` is omitted, with `throttle: 0` as the explicit
unthrottled opt-out. Stream processing, tool handling, and callbacks
should remain immediate; only snapshots exposed to React should be
paced. The default should also guarantee an immediate leading
publication and a terminal flush so the final messages and `ready`
status stay coherent.

This would cap the normal rendering rate at about 20 updates per second
and protect applications that do not know they need to opt in today. The
tradeoff is up to 50ms of additional visible text latency and an
explicit opt-out for applications that intentionally need per-chunk
rendering, which makes the behavior change appropriate for a major
release.

## Related Issues

Addresses the throttled snapshot bypass discussed in #6166. Reports
using the default unthrottled behavior remain outside this PR.

Closes #17893.

Related to cloudflare/agents#2058.
gr2m added a commit to vercel/ai that referenced this pull request Aug 6, 2026
## Background

`useChat` in AI SDK 6 throttles its messages subscription callback, but
its `useSyncExternalStore` snapshot always reads the latest
`chat.messages` array. Because streaming replaces that array for every
chunk, an unrelated React render can observe a new snapshot before the
throttled callback publishes it. In high-frequency streams this bypasses
`experimental_throttle`, causes per-chunk renders, and can contribute to
the "Maximum update depth exceeded" failures reported in #6166.

This is a branch-native backport of #18525 to `release-v6.0`.

## Summary

- Keep a published messages snapshot per `useChat` hook and advance it
from that hook's throttled subscription callback.
- Publish the latest message snapshot before `ready` or `error` becomes
observable, including normal completion and aborts.
- Synchronize updates that occur between render and subscription, and
ignore delayed callbacks after a hook unsubscribes or changes chat
instances.
- Add regression coverage for unrelated renders, terminal status/message
coherence, aborts, errors, and delayed callbacks after chat replacement.
- Turn the existing Next.js throttle route into a deterministic
end-to-end reproduction that streams 500 chunks while forcing unrelated
renders, reports pass/fail from observed snapshot identities, and
verifies the complete message is visible when status becomes `ready`.
- Update the reproduction's stale text stream-part shape to the v6
protocol and avoid a render-counter hydration mismatch.
- Add a patch changeset for `@ai-sdk/react`.

## Contributor Credit

- @brahmveda-arkin reported #6166.
- @takumiz19 isolated the snapshot/subscription mismatch and proposed
#17893.
- @ben-reitz demonstrated the practical value of a conservative UI
update cadence in cloudflare/agents#2058.

## Manual Verification

Ran `/chat/throttle` in `examples/ai-e2e-next` in a real browser. The
route streamed 500 chunks (1,000 assistant characters) with
`experimental_throttle: 50` while a zero-delay timer independently
re-rendered the component.

The patched v6 run passed with 15 distinct message snapshots in 860ms
(maximum expected: 22), across 462 total React renders. All 1,000
assistant characters were visible on the first render where status
became `ready`, with no Next.js error overlay.

Also ran:

- `pnpm --filter @ai-sdk/react test -- use-chat.ui.test.tsx` (60 tests
passed)
- `pnpm check`
- `pnpm type-check:full`

## Checklist

- [x] Tests have been added / updated (for bug fixes / features)
- [ ] Documentation has been added / updated (for bug fixes / features)
- [x] A _patch_ changeset for relevant packages has been added (for bug
fixes / features - run `pnpm changeset` in the project root)
- [x] I have reviewed this pull request (self-review)

## Future Work

This backport intentionally leaves the v6 opt-in default unchanged, so
applications must continue to set `experimental_throttle` to benefit
from paced React publications.

For v8, I recommend making a 50ms UI publication cadence the default
when `throttle` is omitted, with `throttle: 0` as the explicit
unthrottled opt-out. Stream processing, tool handling, and callbacks
should remain immediate; only snapshots exposed to React should be
paced. The default should guarantee an immediate leading publication and
a terminal flush so final messages and `ready` status stay coherent.

This would cap the normal rendering rate at about 20 updates per second
and protect applications that do not know they need to opt in today. The
tradeoff is up to 50ms of additional visible text latency and an
explicit opt-out for applications that intentionally need per-chunk
rendering, which makes the behavior change appropriate for a major
release.

## Related Issues

Backport of #18525.

Addresses the throttled snapshot bypass discussed in #6166. Reports
using the default unthrottled behavior remain outside this PR.

Related to cloudflare/agents#2058.
gr2m added a commit to vercel/ai that referenced this pull request Aug 6, 2026
## Background

`useChat` in AI SDK 5 throttles its messages subscription callback, but
its `useSyncExternalStore` snapshot always reads the latest
`chat.messages` array. Because streaming replaces that array for every
chunk, an unrelated React render can observe a new snapshot before the
throttled callback publishes it. In high-frequency streams this bypasses
`experimental_throttle`, causes per-chunk renders, and can contribute to
the "Maximum update depth exceeded" failures reported in #6166.

This is a branch-native backport of #18525 to `release-v5.0`.

## Summary

- Keep a published messages snapshot per `useChat` hook and advance it
from that hook's throttled subscription callback.
- Publish the latest message snapshot before `ready` or `error` becomes
observable, including normal completion and aborts.
- Synchronize updates that occur between render and subscription, and
ignore delayed callbacks after a hook unsubscribes or changes chat
instances.
- Add regression coverage for unrelated renders, terminal status/message
coherence, aborts, errors, and delayed callbacks after chat replacement.
- Turn the existing Next.js throttle route into a deterministic
end-to-end reproduction that streams 500 chunks while forcing unrelated
renders, reports pass/fail from observed snapshot identities, and
verifies the complete message is visible when status becomes `ready`.
- Update the reproduction's stale text stream-part shape to the v5
protocol and avoid a render-counter hydration mismatch.
- Add a patch changeset for `@ai-sdk/react`.

## Contributor Credit

- @brahmveda-arkin reported #6166.
- @takumiz19 isolated the snapshot/subscription mismatch and proposed
#17893.
- @ben-reitz demonstrated the practical value of a conservative UI
update cadence in cloudflare/agents#2058.

## Manual Verification

Ran `/use-chat-throttle` in `examples/next-openai` in a real browser.
The route streamed 500 chunks (1,000 assistant characters) with
`experimental_throttle: 50` while a zero-delay timer independently
re-rendered the component.

The patched v5 run passed with 15 distinct message snapshots in 1,227ms
(maximum expected: 29), across 654 total React renders. All 1,000
assistant characters were visible on the first render where status
became `ready`, with no Next.js error overlay.

Also ran:

- `NODE_PATH=packages/rsc/node_modules pnpm --filter @ai-sdk/react test
-- use-chat.ui.test.tsx` (53 tests passed; v5's React Vitest config
imports the plugin already pinned by the RSC package but does not
declare it itself)
- `pnpm --filter @ai-sdk/react type-check`
- `pnpm --filter @ai-sdk/react... build`
- `pnpm check`
- `git diff --check`

`pnpm type-check:full` remains red in this checkout because unchanged v5
examples resolve incompatible React type versions. It did not report any
changed file; the changed React package type-check and declaration build
pass, and the changed Next.js route compiled successfully during browser
verification.

## Tasks

- [x] Tests have been added / updated (for bug fixes / features)
- [ ] Documentation has been added / updated (for bug fixes / features)
- [x] A _patch_ changeset for relevant packages has been added (for bug
fixes / features - run `pnpm changeset` in the project root)
- [x] Formatting issues have been fixed (run `pnpm prettier-fix` in the
project root)
- [x] I have reviewed this pull request (self-review)

## Future Work

This backport intentionally leaves the v5 opt-in default unchanged, so
applications must continue to set `experimental_throttle` to benefit
from paced React publications.

For v8, I recommend making a 50ms UI publication cadence the default
when `throttle` is omitted, with `throttle: 0` as the explicit
unthrottled opt-out. Stream processing, tool handling, and callbacks
should remain immediate; only snapshots exposed to React should be
paced. The default should guarantee an immediate leading publication and
a terminal flush so final messages and `ready` status stay coherent.

This would cap the normal rendering rate at about 20 updates per second
and protect applications that do not know they need to opt in today. The
tradeoff is up to 50ms of additional visible text latency and an
explicit opt-out for applications that intentionally need per-chunk
rendering, which makes the behavior change appropriate for a major
release.

## Related Issues

Backport of #18525.

Addresses the throttled snapshot bypass discussed in #6166. Reports
using the default unthrottled behavior remain outside this PR.

Related to cloudflare/agents#2058.
Streaming writes chat state once per chunk, and each write re-renders. A
burst of chunks, such as a resumed stream replaying a long turn, reaches
React's 50-render limit and throws. The AI SDK reports that as a failed
turn even though the server completed it (#1913).

Merging replayed chunks removes one update per chunk but still costs one
update per part, so a turn with enough tool steps fails anyway. A
throttle does not depend on how many chunks arrive: any value above zero
prevents the crash, because the update then arrives from a timer.

The default is 50ms. Measured over a 200-chunk turn at ~100 chunks/sec,
that removes 78% of renders (404 commits to 90), and larger values save
progressively less while lagging further behind the stream. It is also
the value the AI SDK documents. The first chunk is never delayed,
because the SDK throttles with throttleit, which runs the first call of
an idle window immediately.

Both option names carry the value: @ai-sdk/react v3 only reads
experimental_throttle, v4 reads throttle, and both majors are in our peer
range. Passing `throttle: false` omits both names instead of sending 0,
since both majors decide with `waitMs != null` and only an omitted option
reaches their unthrottled path.
@ben-reitz
ben-reitz force-pushed the fix/default-chat-throttle branch from 60edd64 to f8be3ad Compare August 7, 2026 07:24
@ben-reitz

ben-reitz commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

☝🏼 Looks like they're actually turning on throttle by default (with 50ms as the default value) in the next version of the ai SDK, partly off the back of this PR...

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant