跪拜 Guibai
← Back to the summary

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 call JSON.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:

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:

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: