Problem
You want a user to stop an in-flight response without killing resumable streams. Callingstop() on the client only ends the UI stream. The provider keeps generating tokens and you keep paying.
Solution
Store acanceledAt timestamp on the message (not the chat). When the user clicks stop, set it with a tRPC mutation using the current message ID. The stream loop polls canceledAt and aborts the provider with a server-owned AbortController.
Using message-level cancellation (instead of chat-level) is more precise since each message has its own stream state, aligning with the existing activeStreamId pattern on messages.
Prerequisites
- Resumable streams already enabled in
app/(chat)/api/chat/route.ts - Database migrations are up to date
- You have tRPC access from the client
How it works
- Client calls
chat.stopStreamwith the currentmessageId - Server persists
canceledAton the message row - The
onChunkcallback (throttled to 1/sec) pollscanceledAton each token - When
canceledAtis set, callsabortController.abort() - The provider stops and no more tokens are generated
Basic use case
Add a stop mutation and call it from the stop button.trpc/routers/chat.router.ts
components/multimodal-input.tsx
Server-side polling via onChunk
Instead of a separate interval watcher, pollcanceledAt inside the onChunk callback. This fires every time the model emits a token, throttled to once per second to avoid DB spam.
app/(chat)/api/chat/route.ts
onChunk callback is passed through createChatStream into createCoreChatAgent, which forwards it to the AI SDK’s streamText call. Each chunk emitted by the model triggers the throttled check.
Flow
Key files
app/(chat)/api/chat/route.tstrpc/routers/chat.router.tscomponents/multimodal-input.tsxlib/db/schema.tslib/db/queries.ts