Is onFinish called when stream is aborted?
When using streamText and toUIMessageStream with onFinish, is onFinish called when the stream is aborted (e.g. AbortController.abort()) before the model finishes generating? Or is onFinish only called when the stream completes normally?
Now I have a thorough understanding. Let me summarize the findings.
##Answer
The behavior differs between streamText's onFinish and toUIMessageStream's onFinish, and it also depends on how the stream is interrupted. Here's the breakdown from the source code:
###toUIMessageStream's onFinish: ✅ Called on abort
In handle-ui-message-stream-finish.ts, the onFinish callback is invoked from both flush() and cancel() on the final TransformStream (lines 144–160):
// @ts-expect-error cancel is still new and missing from types
async cancel() {
await callOnFinish();
},
async flush() {
await callOnFinish();
},
There's also a finishCalled guard (line 101) ensuring it's only called once. This means:
| Scenario | onFinish called? | isAborted value |
|---|---|---|
AbortController.abort() — abort chunk flows through stream | ✅ Yes (via flush) | true |
reader.cancel() — e.g. browser closes connection | ✅ Yes (via cancel) | false |
| Normal completion | ✅ Yes (via flush) | false |
When AbortController.abort() fires, the resilient stream in stream-text.ts (lines 1065–1076) enqueues an { type: 'abort' } chunk and then closes the stream normally with controller.close(). That abort chunk flows through handleUIMessageStreamFinish, which sets isAborted = true (line 72), and then flush triggers callOnFinish with isAborted: true.
This is confirmed by the test at line 3460:
it('should call onFinish when stream is aborted via AbortController', async () => {
// ...
expect(onFinishCallback).toHaveBeenCalledTimes(1);
expect(callArgs.isAborted).toBe(true);
});
###streamText's own onFinish (the top-level parameter): ⚠️ Depends
The streamText-level onFinish lives in the event processor's flush handler (stream-text.ts lines 957–1050). It has a critical guard:
async flush(controller) {
if (recordedSteps.length === 0) {
// rejects promises with abort reason, returns early
// onFinish is NOT called
return;
}
// ...
await onFinish?.({ ... }); // called only if steps were recorded
}
- If the abort happens before any step completes (i.e., before a
finish-stepevent):recordedStepsis empty →onFinishis NOT called, and the delayed promises (finishReason,usage,steps) are rejected with the abort reason. - If the abort happens after at least one step completed:
onFinishIS called with the data from the completed steps.
###streamText's onAbort: The dedicated abort callback
For the streamText-level abort handling, there's a separate onAbort callback (line 1066):
function abort() {
onAbort?.({ steps: recordedSteps });
controller.enqueue({ type: 'abort', ... });
controller.close();
}
This is always called when the abort signal fires, giving you access to whatever steps completed before the abort.
###Also: Breaking iteration / reader.cancel() does NOT call streamText's onFinish
The tests explicitly confirm (lines 3556-3639):
it('should NOT call onFinish when for-await loop breaks early', ...);
it('should NOT call onFinish when reader.cancel() is called', ...);
However, toUIMessageStream's onFinish IS still called on reader.cancel() (thanks to the cancel() handler), though with isAborted: false.
###Summary
| Callback | AbortController.abort() | reader.cancel() / break | Normal completion |
|---|---|---|---|
toUIMessageStream({ onFinish }) | ✅ Called, isAborted: true | ✅ Called, isAborted: false | ✅ Called, isAborted: false |
streamText({ onFinish }) | ❌ Not called (if no steps completed) / ✅ Called (if steps completed) | ❌ Not called | ✅ Called |
streamText({ onAbort }) | ✅ Always called | ❌ Not called | ❌ Not called |
END OF POST