Skip to content

Output.array streaming re-parses and re-validates every element on every chunk (O(n²)), blocking the Node event loop on long streams #24317

Open

Description

@AlfredBeNoel

Description

How we use it. streamText with output: Output.array({ element }), consuming elementStream, in a Node.js server. We stream generated items (up to 200 per request) and persist each element as it arrives.

What we see. While one long Output.array stream runs, the process's event-loop utilization climbs steadily as the output grows, and other work on the same process waits. The load drops back each time a new stream starts. Models that stream in smaller chunks make it much worse.

Cause. For every text chunk, streamText calls output.parsePartialOutput({ text }) on the full accumulated text. For Output.array that means, on every chunk:

  1. parsePartialJson on the whole buffer,
  2. safeValidateTypes on every completed element (not only the new one),
  3. JSON.stringify(result.partial) of the whole array, to compare with the last published value.

So the work is roughly chunks × elements: it grows with the square of the output length, and one-token chunks multiply it. In ai@7.0.159 this is dist/index.js around lines 3512–3517 (the rawElements loop) and 8922–8924 (the per-chunk call and the JSON.stringify compare).

Expected. Each element is parsed and validated once, so a 200-element stream costs about 10× a 20-element stream.

Actual. The cost is quadratic. With the repro below (mocked stream, ~850-byte elements, 12-character chunks), on one thread:

elements element validations CPU
50 85,996 0.7 s
100 347,380 2.1 s
200 1,397,680 8.3 s

With our real element schema (~1.3 KB per element) on ai@6.0.201, 200 elements cost 22.5 s of CPU at 12-character chunks (streamText + Output.array), and 66.7 s at 4-character chunks (streamObject with output: 'array', which does the same per-chunk work), measured on a laptop. A cloud vCPU is slower.

Related. #22663 and #22877 described parts of this from a code audit and were closed as not actionable. The fix PR #22716 was closed unmerged. This report comes from real usage.

Ask. Keep incremental state per stream for Output.array, so it parses only the new text and validates each element once. Alternatively, offer an option to skip partial-output parsing when only elementStream is consumed. Our workaround for now: stream plain text with a JSON response format, parse it with an incremental JSON parser, and validate each element once.

Reproduction

Mocked stream only, with no network and no credentials.

npm init -y && npm install ai@7.0.159 zod@4
node repro.mjs 50 12 && node repro.mjs 100 12 && node repro.mjs 200 12

repro.mjs (arguments: element count, characters per chunk):

// Standalone: mocked stream, no network or credentials. Times streamText + Output.array on one thread.
import { streamText, Output } from "ai";
import { z } from "zod";

const count = Number(process.argv[2] ?? 200), piece = Number(process.argv[3] ?? 12);
let validations = 0;
const element = z.object({
  question: z.string(), choices: z.array(z.string()).length(4), answerIdx: z.number(), explanation: z.string(),
}).superRefine(() => { validations++; });
const words = "the cell membrane controls which substances enter and leave the cell ";
const text = JSON.stringify({ elements: Array.from({ length: count }, (_, i) => ({
  question: `Q${i} ${words.repeat(2)}`, choices: [1, 2, 3, 4].map((c) => `${c} ${words}`), answerIdx: i % 4, explanation: words.repeat(5),
})) });
const result = streamText({
  prompt: "Return elements",
  output: Output.array({ element }),
  model: { specificationVersion: "v4", provider: "mock", modelId: "mock", supportedUrls: {},
    async doStream() { return { stream: new ReadableStream({ start(c) {
      c.enqueue({ type: "stream-start", warnings: [] });
      c.enqueue({ type: "text-start", id: "t" });
      for (let i = 0; i < text.length; i += piece) c.enqueue({ type: "text-delta", id: "t", delta: text.slice(i, i + piece) });
      c.enqueue({ type: "text-end", id: "t" });
      c.enqueue({ type: "finish", finishReason: { unified: "stop", raw: "stop" }, usage: { inputTokens: { total: 1 }, outputTokens: { total: 1 } } });
      c.close();
    } }) }; } },
});
const t0 = performance.now(), c0 = process.cpuUsage();
let got = 0;
for await (const _ of result.elementStream) got++;
const cpu = process.cpuUsage(c0);
console.log(`${count} elements, ${Math.round(text.length / 1024)} KB, ${Math.ceil(text.length / piece)} chunks of ${piece} chars: ` +
  `${got} emitted, ${validations} element validations, ${((cpu.user + cpu.system) / 1e6).toFixed(1)} s CPU`);

Output on Node 22.22.3:

50 elements, 41 KB, 3509 chunks of 12 chars: 50 emitted, 85996 element validations, 0.7 s CPU
100 elements, 82 KB, 7017 chunks of 12 chars: 100 emitted, 347380 element validations, 2.1 s CPU
200 elements, 165 KB, 14042 chunks of 12 chars: 200 emitted, 1397680 element validations, 8.3 s CPU

AI SDK Version

  • ai: 6.0.201 (where we found it). Still present in 6.0.320 and 7.0.159.
  • @ai-sdk/openai: 3.0.127
  • Node.js: 22

Code of Conduct

  • I agree to follow this project's Code of Conduct

Activity

  1. ai-sdk-factory commented on Oct 31, 2026

    @ai-sdk-factory
    Contributor

    Identify issue type

    Identified as Bug
    Confidence: High

    Rationale

    This is a concrete, reproducible performance failure in existing streaming behavior that blocks unrelated requests. The proposed incremental parsing changes address that failure rather than merely adding a new capability.

    Evidence

    • Reports three production outages caused by Output.array streaming blocking the Node.js event loop for 40–87 seconds.
    • Provides a standalone reproduction showing repeated validation of completed elements and quadratic CPU growth: 85,996 validations for 50 elements versus 1,397,680 for 200.
    • Reports the behavior in ai@6.0.201, 6.0.320, and 7.0.159.
  2. ai-sdk-factory commented on Oct 31, 2026

    @ai-sdk-factory
    Contributor

    Identify bug discovery

    Discovery source: discovery:usage (high confidence).

    The author explicitly traces discovery to repeated production outages in a quiz-generation API. The source inspection and mocked reproduction were subsequent investigation, not the discovery source.

    • Uses streamText with Output.array and elementStream on a Node.js API server to generate up to 200 quiz questions and persist each element.
    • Reports three production outages in five days, with event-loop blocks lasting 40–87 seconds that caused other requests to hang.
    • Identifies ai@6.0.201 as the production version 'where we found it.'
    • Explicitly states: 'This report comes from production use.'
  3. added
    discovery:usageDiscovered through real application or product usage, rather than an audit.
    on Oct 31, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions