XBSTACK XBSTACK
Xiaobai

Xiaobai

Developer · Builder

Building AI engineering systems, developer tools and long-term digital assets at XBSTACK.

About Xiaobai & XBSTACK →
Vercel AI SDK streamObject result promises remaining pending after an error and the application failure-fence workaround

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.

Published · 2026-08-156 min readXBSTACK
#Vercel AI SDK#AI SDK 7#streamObject#Structured Output#Error Handling#TypeScript#Streaming#Production Engineering

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.

ItemTest setting
AI SDK[email protected]
Zod4.4.3
Providerdeterministic local mock
External APInone
Result-promise observation1.5 seconds
ControlstreamText()

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:

  1. drain fullStream in the background so stream-level failures are observed;
  2. turn onError into a dedicated rejecting promise;
  3. 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:

StateUseful evidence
provider errorerror type, provider, model, request id
stream terminal stateobserved error / finish / abort
structured resultfulfilled / rejected / application deadline
request lifecycleclient 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:

  1. doStream() fails before output;
  2. the stream starts, emits an error part, 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.

If your symptom is “the request did not throw, but await result.object never comes back,” use this order:

  1. verify the installed ai version; this page reproduced on the current npm release 7.0.66 on August 15, 2026;
  2. observe fullStream so you know whether the provider already emitted an error;
  3. do not treat result.object as the only terminal signal;
  4. add an application failure fence with provider-error propagation, a deadline, and AbortController cleanup;
  5. log provider error, stream terminal state, structured-result state, and request lifecycle separately;
  6. 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

Experiment path / AI SDK

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 →
Vercel AI SDK 7 Migration: Interrupted Streams, Cloudflare 524 Boundaries, and Tool-Call RecoveryVercel AI SDK 7 production migration: Node.js 22, ESM, ToolLoopAgent, WorkflowAgent, tool approval, interrupted streams, persistence, retry boundaries and recovery.Kimi K3 Test: Coding Ability, Kimi Code, 1M Context, and Real Project ResultsKimi K3 coding test on a real Astro project: cross-file analysis, code review, self-correction, Kimi Code, k3-256k, 1M context access, membership, and cache-switching boundaries.ChatGPT Chat vs Work: What’s the Difference? When to Use CodexChatGPT Work vs Codex: compare Chat, Work, and Codex by task boundary, then check the latest Cloud Work, Local Chat, Mobile Remote, and Voice behavior.GPT-5.6 Coding Review: Real Astro Project, GSC and GA4 TestsGPT-5.6 coding review based on a real Astro project, content work, Search Console and GA4 analysis, plus when Sol, Terra and Luna make sense.

AI Engineering Weekly

Production changes, real failures, experiments and new XBSTACK assets.

Comments & evidence

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.

Sign-in required Reviewed before public
Loading the discussion…