Aborting streamObject Leaves usage as an Unhandled Rejection
This article may contain affiliate links. Its content is not affected by advertising.
In short
Aborting streamObject rejects result.usage, and if the caller returns from a catch path without awaiting done, nothing observes it and Node reports an unhandled rejection.
Conclusion
Wire an AbortSignal into the Vercel AI SDK’s streamObject so it can be interrupted, and on abort result.usage — a promise that settles after the stream completes — rejects. If the caller catches the throw from elementStream and simply returns, it never awaits result.done (which wraps usage), and the rejected promise is left for nobody to observe. Node reports it as an unhandled rejection.
Symptom
To guard against a model that stops responding, the SSE handler that generates notice drafts for teachers gained a stall timer: if no element has been produced for a set period, an AbortController interrupts and the request is folded into an error (STREAM_STALL_MS).
// apps/web/lib/editor/notice-draft-sse.ts:215-245 (excerpt)
const result = deps.streamClient.stream({
system: NOTICE_ASSIST_STREAM_SYSTEM,
user: buildNoticeAssistUser(masked, jstDateLabel(now), adjust),
signal: stallController.signal,
});
for await (const el of result.elementStream) {
armStall(); // reset the stall clock on every piece of progress
// ...handle the element...
}
await result.done;
} catch {
// model/transport failure, or a stall abort (stallController.abort).
send("error", { status: 500, reason: "stream_failed", message: "応答の生成に失敗しました。" });
return;
}
When the abort (stallController.abort()) fires during the for await loop, elementStream throws and control enters catch. That path returns without reaching the final await result.done, so done — which is internally waiting on usage — is never referenced by anyone.
Cause
The streamObject side looked like this.
// packages/ai/src/model/notice-draft-stream.ts:106-124 (excerpt, pre-fix)
stream(req: { system: string; user: string }): NoticeDraftStreamResult {
const result = streamObject({
model, schema: noticeElementSchema,
system: req.system, prompt: req.user,
...genOptions,
});
const done = (async () => {
const { usage } = await result.usage;
return { tokenCount: usage?.outputTokens ?? 0 };
})();
return { elementStream: result.elementStream, done };
}
done is the promise of an async function that awaits result.usage. When the stream terminates abnormally — an abort, or a transport failure — result.usage itself rejects. If the caller reaches await result.done as it does on the happy path, that rejection is caught by the surrounding try/catch. But as described, a stall abort surfaces as elementStream throwing inside the loop, and by the time the handler is in catch it returns without going through await result.done.
At that point done — waiting on an already-rejected result.usage — is a promise that nothing ever awaits or catches. Node treats unhandledRejection as an unhandled exception by default and, with no handler registered, terminates the process. The abort feature itself works correctly; the path that detects the abort simply does not wait on done, and that alone strands a rejected promise.
The fix
Attach one no-op catch to the done that stream() returns, regardless of what the caller does.
// packages/ai/src/model/notice-draft-stream.ts:132-138 (excerpt)
const done = (async () => {
const { usage } = await result.usage;
return { tokenCount: usage?.outputTokens ?? 0 };
})();
// On abort (abortSignal) or a mid-stream failure, result.usage rejects. The handler can
// exit through catch on the elementStream throw without awaiting done (stall abort, etc.),
// so attach one no-op handler to avoid an unhandledRejection. The returned done is
// unchanged and callers can still await it for the value.
done.catch(() => {});
return { elementStream: result.elementStream, done };
The important part is that done itself is still what gets returned after done.catch(() => {}). .catch() does not change the state of the original promise; it only makes the rejected state observed. On the happy path, where the caller reaches await result.done, usage is available exactly as before. The no-op only matters on paths that never await done, where it keeps a rejected promise from being reported as an unhandled rejection.
Preventing a repeat
This is the same contract that was applied when a production hang on another streaming path to Vertex (the chat assistant, #986) was fixed in three layers and review flagged the same class of unhandled rejection. Porting the stall-abort mechanism to the notice-draft SSE handler meant porting done.catch(no-op) with it, so the two paths do not diverge with the guard present on only one.
Generalised: when you add an abort path to an API that returns a promise settling after completion, attach a no-op catch on the returning side unless you can guarantee every caller awaits it. Depending on how the caller’s try/catch is shaped, an await written for the happy path can be skipped by some failure paths — and that is the kind of gap a review of the caller alone rarely catches.
Frequently asked questions
Q1Why does result.usage reject when I abort?
usage is returned as an async promise that settles after the stream completes. Aborting via AbortSignal makes that completion path an abnormal termination rather than a normal one, so the promise waiting on usage rejects.
Q2Why does nothing catch that rejection?
On the happy path the handler reads elementStream to the end and then awaits result.done. On a stall abort, elementStream itself throws, the handler enters catch, sends an error response and returns. That path never reaches the await, so the rejected promise is left unreferenced.
Q3Does adding done.catch(() => {}) stop the caller getting usage?
No. It attaches a side-effect-free no-op handler to the same result.done object, and that object is still what gets returned. The happy path where the caller awaits result.done for usage is unchanged. It only insures against paths that never await done.
Q4Where did this pattern come from?
The same Vertex call in the chat SSE handler had a production hang fixed in three layers, and review flagged the unhandled rejection on that path at the same time. When the stall abort was ported to the notice-draft SSE handler, done.catch(no-op) was ported with it.
Environment verified
- ai (Vercel AI SDK) ^5.0.52 / @ai-sdk/google-vertex ^3.0.140
- Fixed 2026-06-21 (PR #1104 / #987). The equivalent chat-side fix (#986) was 2026-06-16
What this article is based on
- TypeScript file lines 106-140commit a6d437f
- TypeScript file lines 88-245commit a6d437f
- JSON file lines 27-29commit 9f1bff4
Every claim in this article comes from the records above. The repositories we operate are private so we cannot link to them, but which file, which lines, and at which commit we read them is recorded for every article. Nothing here is written from guesswork.