Repository navigation
Output.array streaming re-parses and re-validates every element on every chunk (O(n²)), blocking the Node event loop on long streams #24317
Description
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:
parsePartialJsonon the whole buffer,safeValidateTypeson every completed element (not only the new one),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 12repro.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
ai-sdk-factory commentedon Oct 31, 2026
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
ai-sdk-factory commentedon Oct 31, 2026
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.