Streaming LLM JSON: The 2026 Frontend Hurdle Is Rendering Before the Closing Brace
Is your AI feature still waiting for the JSON to finish before rendering?
A model typically needs hundreds of tokens to generate a complete structured JSON, taking 5 to 15 seconds. If you sit in the
reader.read()loop and only callJSON.parse()when the last}arrives, the user will stare at a blank card for over ten seconds — you've erased the meaning of streaming with your own hands.But "render while receiving" isn't a one-liner of
partial-json. It has three concrete engineering hurdles: how to complete the half-baked JSON, how to avoid flicker once completed, and how to ensure the completed data isn't misused.
1. First, distinguish between two completely different layers of JSON
Many developers new to streaming structured output confuse one thing: "In SSE, I already used buffer to handle partial data, right?"
You're dealing with the outer envelope, not the inner business JSON.
The outer layer is the SSE transmission envelope, one line per event. Once you see the newline \n, the line is complete—milliseconds:
data: {"choices":[{"delta":{"content":"{\"title\":"}}]}
data: {"choices":[{"delta":{"content":"\"降噪耳"}}]}
The inner layer is the business JSON built by concatenating all delta.content. The model might stop at any character:
At t1: {"title":
At t2: {"title":"降噪耳
At t3: {"title":"降噪耳机"}
Splitting lines by \n and JSON.parse-ing each line for the envelope is correct. But the inner layer can't wait — you must "complete" a legal object from the half-finished JSON, then render it.
2. How to "complete" half-baked JSON? The core is a stack
The browser's native JSON.parse is an "all-or-nothing" strict parser. If the end is missing a quote or a bracket, it immediately throws SyntaxError and won't return the already-parsed fields.
So you need a "best-effort" completion tool. Its job breaks down into three steps: trim the dangling tail, complete quotes, and close brackets.
Trim the dangling tail: shave off the hanging bit
The model can stop at awkward positions. The completion tool needs to detect the current truncated state and perform the corresponding "cleanup":
| Stopped at | Received half text | Completion strategy |
|---|---|---|
| Middle of string value | {"title":"AI 周 |
Add closing quote + closing brackets |
| Middle of escape sequence | {"text":"中\u4e |
Shave off the incomplete \u4e, then add quote and brackets |
| Half key | {"title":"周报","ite |
Key is meaningless unfinished, roll back and remove "ite |
| Key present no value | {"title":"周报","items": |
Roll back and remove the dangling "items": |
| Trailing comma | [{"name":"A"}, |
Remove the trailing hanging comma |
| Half boolean | {"ok":tr |
Starting with t can only be true, so complete it |
Complete brackets: use a stack to remember what's owed
How do you know whether to append ] or }] at the end? The answer is a simple stack:
Scan the characters from the start, maintaining two states:
- Inside a string or not: toggle state on each unescaped
". Braces/brackets inside strings don't count. - Bracket stack: outside strings, each
{pushes}, each[pushes]; closing}or]pops.
When you need to render, first shave off a trailing comma, colon, or half key; if you stopped in the middle of a string value, append a "; finally, append the remaining stack brackets in reverse order to the end.
This 35-line minimal code shows the core principle. For production use partial-json, but understanding these 35 lines explains why some fields appear early and some are temporarily discarded:
function miniCompleteJson(partialStr) {
let inString = false;
let escaped = false;
const closers = [];
for (const ch of partialStr) {
if (inString) {
if (escaped) escaped = false;
else if (ch === '\\') escaped = true;
else if (ch === '"') inString = false;
continue;
}
if (ch === '"') inString = true;
else if (ch === '{') closers.push('}');
else if (ch === '[') closers.push(']');
else if (ch === '}' || ch === ']') closers.pop();
}
let fixed = partialStr;
if (inString) {
if (escaped) fixed = fixed.slice(0, -1);
fixed += '"';
}
fixed = fixed.replace(/,s*"[^"]*"?s*:?s*$/, '').replace(/[,:s]+$/, '');
return fixed + closers.reverse().join('');
}
3. After completing, how to make the page "not flash, not jitter, not stutter"?
Being able to parse a partial object is only the first step. If you throw the half-finished result directly at the UI, you'll hit three experience problems: blank white screen on component error, numbers flickering constantly, and long JSON parsing freezing the main thread.
Problem 1: Half-baked number flicker
The price field goes from {"price": to {"price":1 to {"price":12 to {"price":123. If you render every frame, the user will see the price jumping around wildly.
Solution: In the completion config, exclude half-baked numbers. partial-json's Allow bitmask can allow only strings, objects, and arrays in an incomplete state; numbers must wait until complete.
Problem 2: setState on every chunk saturates the main thread
The model may emit tens of thousands of chunks. Real measurements are telling:
| Strategy | Total time for 27,000 chunks |
|---|---|
Full JSON.parse on every chunk |
~32.7 seconds (O(n²) quadratic blowup) |
Incremental parse on every chunk, but calling JSON.parse each time |
~8.3 seconds |
| Throttle by frame/timer (parse every 50 chunks) | ~0.53 seconds |
The parse count drops from 27,000 to around 500, turning "frozen" into "smooth".
Pick the throttle strategy by scenario:
- Animation-sensitive (normal cards): use
requestAnimationFrameto align with the screen refresh rate; at 60Hz, parse and render at most once per 16ms. - Heavy components / virtual DOM frameworks (React/Vue large tables): use a 50ms to 80ms timer throttle, updating 12–20 times a second; it still looks continuous to the eye, and framework re-render cost drops another 70%.
Problem 3: Components crash when fields are missing
In partial data, items may not exist yet, and summary might be an empty string. The UI layer must apply safe default values to every field rather than directly accessing data.items.map().
A workable pattern is to write a toCardView(partialObj) adapter function that returns a safe placeholder for any missing or mis-typed field, and have the UI consume only the adapted result.
4. Ready to use: pure JS core, works with native/Vue/React
Split the logic into two layers: the bottom is a zero-framework-dependency pure JS stream consumer, the top is a UI integration snippet under 10 lines.
// partialStream.js —— works with native, Vue, React, mini programs
import { parse, Allow } from 'partial-json';
function extractJsonBody(raw) {
const fenceMatch = raw.match(/```(?:json)?s*n?([sS]*)/i);
if (fenceMatch) return fenceMatch[1].replace(/s*```s*$/, '');
const firstBracket = raw.search(/[{\[]/);
return firstBracket === -1 ? '' : raw.slice(firstBracket);
}
export async function consumePartialJson(body, onUpdate, throttleMs = 40) {
const reader = body.pipeThrough(new TextDecoderStream()).getReader();
let rawText = '';
let timerId = null;
const flush = () => {
timerId = null;
const jsonCandidate = extractJsonBody(rawText);
if (!jsonCandidate.trim()) return;
try {
const partialObj = parse(jsonCandidate, Allow.STR | Allow.OBJ | Allow.ARR);
onUpdate({ data: partialObj, done: false });
} catch { /* too few chars, wait for next beat */ }
};
while (true) {
const { done, value } = await reader.read();
if (done) break;
rawText += value;
if (timerId === null) timerId = setTimeout(flush, throttleMs);
}
if (timerId !== null) clearTimeout(timerId);
const finalJson = JSON.parse(extractJsonBody(rawText)); // final full validation
onUpdate({ data: finalJson, done: true });
}
React integration:
const [state, setState] = useState({ data: {}, done: false });
async function generate() {
const res = await fetch('/api/product-card', { method: 'POST' });
await consumePartialJson(res.body, setState);
}
Vue 3 integration is similar: pass onUpdate as the assignment function for a ref.
5. How to choose a tool? A table for your situation
Don't stress about reinventing the wheel; pick based on your project:
| Your scenario | Recommended solution | Why |
|---|---|---|
| Regular self-built SSE/Fetch stream | partial-json | Zero dependencies, small size, Allow bitmask disables half-baked numbers to prevent flicker |
| Model tends to output single quotes, comments, trailing commas | jsonrepair | Extremely tolerant, repairs all kinds of "bad JSON" into standard form |
| Next.js/React + AI SDK | Vercel AI SDK's useObject |
Full-stack, handles completion, throttling, and Schema validation |
| Huge JSON, thousands of lines of streaming list | @streamparser/json | Event-driven true O(n) parser, calls back when each array element closes, doesn't rescan earlier text |
6. Three production safety lines: partial data "looks too much like real"
This is the most easily overlooked part of the article, but it has the most serious consequences. The object produced by the completion tool looks nearly identical to the complete data, but it may be missing half its fields.
Safety line 1: Partial data is for display only, never submit
Network interruptions, the user clicking "stop generation", or the model hitting max token length—in all these cases the completion tool still gives you a legal JSON object. Writing to a database, submitting a form, or triggering the next API call must use done === true and pass a full validation via native JSON.parse or zod.safeParse as the only criteria.
Safety line 2: Agent tool-call arguments are never executed before completion
The model is generating a tool call to delete a directory. The partial argument {"path": "/data gets completed into a legal {"path": "/data"}, but the model was actually writing /data/tmp/cache. Arguments parsed during streaming may only be used for UI preview; the actual tool execution must wait for the stream to end and pass validation.
Safety line 3: Structured doesn't mean secure
If the card has URL fields like link or avatar, validate that the protocol is https:// before rendering, to prevent the model from being induced to output a javascript:alert(1) pseudo-protocol. Plain text fields should stick to text interpolation (textContent, {{ title }}, {title}), not be inserted directly into innerHTML or v-html.
In conclusion
In 2026, the frontend of AI applications is no longer just a "chat box". Models are returning structured data — product cards, streaming tables, form fills, Agent tool-call arguments. And streaming structured output has an inescapable engineering reality: until the last } arrives, you're always holding only half a JSON.
Turning that half JSON into renderable UI requires three things: a completion tool that can trim dangling tails and close brackets; a throttle valve aligned to frames or milliseconds; and a safety discipline of "partial data is display-only, never submit".
The meaning of streaming is not "transmit faster" but "let users see something useful earlier". If your rendering still waits for the JSON to close, then streaming for you is just moving the waiting from the server to the client.
Reference links: