Xiaobai
Developer · Builder
Building AI engineering systems, developer tools and long-term digital assets at XBSTACK.
About Xiaobai & XBSTACK →
AI SDK streamObject() Hangs After Errors: Why result.object Can Stay Pending
AI SDK 7.0.66 repro: streamObject can leave result.object and related promises pending after provider errors even when fullStream is consumed. Includes a tested failure fence.
AI SDK streamObject() Hangs After Errors: Why result.object Can Stay Pending
Here is the result first: on August 15, 2026, I reproduced a terminal-state problem in the current npm release, [email protected]. If a provider fails before producing output, or if its stream starts and later emits an error part before closing, result.object, result.usage, result.finishReason, result.response, and result.warnings can remain pending even after the failure path has ended.
I also tested the obvious counterargument. I explicitly consumed fullStream, observed the provider error, and saw onError run. The five result promises still did not settle. Under the same early-failing model shape, streamText() behaved differently: finishReason, totalUsage, and response rejected instead of remaining pending.
For production code, the temporary containment is to stop treating result.object as the only terminal signal. Consume fullStream, convert onError into a rejecting promise, and race that signal against result.object and a deadline. The failure fence in the local experiment rejects promptly in both error fixtures. It protects the caller; it does not fix the unresolved promises inside the SDK.
The upstream report is Vercel AI SDK issue #18930. Vercel originally introduced the final typed object promise as a way to use the finished structured result after streaming, and earlier reports such as issue #5027 show that streamObject error propagation has been a practical boundary before. This page is not a rewrite of those reports: it puts the current release into deterministic offline fixtures and verifies exactly what settles and what does not.
First check: this is the current release, not a stale 7.0.x build
Before building the reproduction I checked npm directly:
npm view ai version
On August 15 it returned:
7.0.66
The experiment therefore pins the version npm itself reports as current on the test date. It does not use OpenAI, Anthropic, Gemini, Bedrock, AI Gateway, or any real provider. The model is a minimal deterministic LanguageModelV4 mock so the failure timing can be controlled precisely.
| Item | Test setting |
|---|---|
| AI SDK | [email protected] |
| Zod | 4.4.3 |
| Provider | deterministic local mock |
| External API | none |
| Result-promise observation | 1.5 seconds |
| Control | streamText() |
The 1.5-second value is not a recommended model timeout. In these fixtures the error stream has already terminated. The window only asks whether a delayed result promise settles after the operation has already failed.
Reproduction 1: provider failure before output leaves five promises pending
The first mock fails directly from doStream():
function failingModel(errorMessage) {
return {
specificationVersion: 'v4',
provider: 'xbstack.mock',
modelId: 'intentional-failure',
async doStream() {
throw new Error(errorMessage);
},
};
}
Then streamObject() is created normally:
const result = streamObject({
model: failingModel('streamObject provider failure'),
schema: z.object({ content: z.string() }),
prompt: 'hello',
});
Instead of letting the reproduction itself hang forever, each result promise is observed through a 1.5-second race. All five time out:
streamObject.object -> timeout
streamObject.usage -> timeout
streamObject.finishReason -> timeout
streamObject.response -> timeout
streamObject.warnings -> timeout
That alone would only prove an initialization/early-provider path, so I added a second fixture.
Reproduction 2: the stream starts, emits an error part, closes — and results still do not settle
The second mock returns a real ReadableStream, starts it, emits an error part, and closes:
async doStream() {
return {
stream: new ReadableStream({
start(controller) {
controller.enqueue({ type: 'stream-start', warnings: [] });
controller.enqueue({
type: 'error',
error: new Error('streamObject mid-stream error part'),
});
controller.close();
},
}),
};
}
The five result promises again time out.
There is another subtle result in this path: onFinish can run with a unified finishReason of other and empty token-detail objects, while the final result promises are still pending. In other words, a lifecycle callback firing does not prove that the structured result is now safe to await.
That makes this kind of bookkeeping risky:
onFinish(() => {
markJobFinished();
});
For structured generation, a production job should distinguish at least the stream lifecycle from the validated final-object lifecycle.
Counter-test: explicitly consuming fullStream does not settle the result promises
A reasonable objection is that streamObject() is a streaming API and perhaps the test simply forgot to consume the stream. I therefore added a third check that explicitly drains result.fullStream:
for await (const part of result.fullStream) {
console.log(part.type);
}
The application sees the expected error:
error -> streamObject consumed error part
fullStream itself completes, and onError receives the same provider error. So the failure is observable at the stream layer.
The five result promises are then checked again:
object -> timeout
usage -> timeout
finishReason -> timeout
response -> timeout
warnings -> timeout
This matters because it rules out the simplest explanation. In the tested [email protected] path, draining the stream is not sufficient to move these delayed result promises into a fulfilled or rejected state.
Why the streamText control matters
Testing streamObject by itself would only show that these two fixtures hang. To see whether this is the general terminal contract for all AI SDK streaming functions, I ran streamText() with the same early-failing mock model.
The corresponding result promises behave differently:
streamText.finishReason -> rejected
streamText.totalUsage -> rejected
streamText.response -> rejected
They reject with No output generated. Check the stream for errors. rather than staying pending.
The current streamText result interface also explicitly describes its promise-backed results as auto-consuming the stream. That does not prove every streamText failure mode is correct, but it makes this a useful control: the same early model failure does not reproduce the pending result behavior there.
For the broader AI SDK 7 migration, Tool Call, persistence, Abort, retry, and proxy-boundary work, use AI SDK 7 Migration in Production. This page intentionally stays focused on one streamObject terminal-state problem.
Temporary workaround: do more than wrapping result.object in a timeout
The smallest possible guard is:
await Promise.race([
result.object,
timeout(10_000),
]);
It prevents an infinite wait, but it can throw away the useful provider error and leave you with only a generic deadline message.
The tested failure fence uses three signals instead:
- drain
fullStreamin the background so stream-level failures are observed; - turn
onErrorinto a dedicated rejecting promise; - race
result.object, that provider-error promise, and a deadline, then abort on cleanup.
The core shape is:
async function streamObjectWithFailureFence({ model, timeoutMs = 10_000 }) {
const controller = new AbortController();
let rejectObservedError;
const observedError = new Promise((_, reject) => {
rejectObservedError = reject;
});
const result = streamObject({
model,
schema,
prompt,
abortSignal: controller.signal,
onError({ error }) {
rejectObservedError(
error instanceof Error ? error : new Error(String(error)),
);
},
});
const consume = (async () => {
for await (const _part of result.fullStream) {
// Drain the stream so stream-level errors are observable.
}
})();
const deadline = new Promise((_, reject) => {
setTimeout(
() => reject(new Error('streamObject deadline exceeded')),
timeoutMs,
);
});
try {
return await Promise.race([
result.object,
observedError,
deadline,
]);
} finally {
controller.abort();
await consume.catch(() => undefined);
}
}
The local regression produces:
early provider failure -> rejected with original provider error
provider error part -> rejected with original provider error
This is better than a timeout-only wrapper because the provider error remains visible when available and the deadline still protects the request if no terminal signal arrives.
It is important to keep the scope precise: this is containment, not an SDK repair. The internal result promises may still be pending; the application simply stops using them as the only way to decide whether the request is over.
What to log in production
If an API route, queue worker, or agent run depends on streamObject(), keep these states separate:
| State | Useful evidence |
|---|---|
| provider error | error type, provider, model, request id |
| stream terminal state | observed error / finish / abort |
| structured result | fulfilled / rejected / application deadline |
| request lifecycle | client cancellation, server abort, total latency |
Do not collapse onFinish, HTTP connection close, fullStream completion, and “validated object available” into a single finished=true flag.
If the structured output controls a side effect such as a database write, email, order, deployment, or tool execution, do not trigger the next stage merely because onFinish ran. Require the final object to be present and schema-valid first.
When avoiding streamObject is reasonable
If the backend only needs one final validated object and the UI does not show incremental structured fields, a non-streaming structured-output path can be operationally simpler. Streaming adds value when:
- the interface needs progressive structured fields;
- long object generation should become visible early;
- the service already has explicit stream lifecycle, abort, timeout, and telemetry controls;
- provider-specific streaming behavior is covered by regression tests.
For a backend job that only needs JSON before a database write, the additional stream lifecycle may not be worth the failure surface.
Test boundary
The experiment covers two deterministic failures:
doStream()fails before output;- the stream starts, emits an
errorpart, and closes.
It does not claim that every OpenAI, Anthropic, Bedrock, AI Gateway, or other provider path must behave identically. Provider adapters, SSE transports, network failures, and abort timing introduce separate behavior that needs its own fixtures.
The 1.5-second observation window is also not a production SLA. It is used only because the deterministic stream has already terminated and no more output can arrive. Set production deadlines according to the provider, model, proxy layer, and business tolerance.
Recommended handling order
If your symptom is “the request did not throw, but await result.object never comes back,” use this order:
- verify the installed
aiversion; this page reproduced on the current npm release7.0.66on August 15, 2026; - observe
fullStreamso you know whether the provider already emitted an error; - do not treat
result.objectas the only terminal signal; - add an application failure fence with provider-error propagation, a deadline, and AbortController cleanup;
- log provider error, stream terminal state, structured-result state, and request lifecycle separately;
- when upstream ships a fix, rerun the same early-failure, error-part, explicit-consumption, and streamText control matrix before removing the guard.
A pending Promise looks small in a unit test. In production it can hold an API route, worker slot, concurrency permit, or user request indefinitely. For structured streaming, terminal semantics are part of reliability—not an implementation detail.
Continue with AI SDK 7 Migration in Production · AI Tools Lab
Connect the migration article to reproducible experiments
Use the AI Tools Lab to review migration diffs, tool-call behavior, persistence, abort, retry and timeout evidence instead of relying on release-note summaries.
More to Explore
Topic hub →AI Engineering Weekly
Production changes, real failures, experiments and new XBSTACK assets.
DISCUSSION
Questions, verification and corrections
Sign in to comment. Every new comment is reviewed before publication; while pending, it is visible only to you and the administrator.