Parsing Partial JSON From Streaming LLM Responses in October 2026: Render Fields as They Arrive Without Acting on Half-Written Data
Streaming a model's answer token by token feels fast, but it gets awkward the moment the answer is structured JSON. A chat bubble can show half a sentence. A form, a table, or a product card cannot show half of a JSON document, because JSON.parse throws on every chunk until the very last one. Teams usually end up with one of two bad outcomes: a spinner until the whole response arrives, which throws away the latency benefit of streaming, or a pile of regexes that break on the first escaped quote.
This post covers a middle path: parse the partial text on every chunk, render the fields that are already complete, and keep a hard line between "good enough to display" and "safe to act on." The code below is plain JavaScript with no dependencies, and the pieces carry over to any language.
Why partial JSON is a different problem from partial text
A truncated JSON string is invalid in specific, predictable ways. At any cut point, the text ends in one of a small number of states:
- Inside a string value, possibly right after a backslash or in the middle of a
\u00e9escape. - Right after a key, a colon, or a comma, with no value yet.
- In the middle of a number such as
12that might become123, or1.or1e, which are not valid numbers yet. - In the middle of a literal such as
truornul. - With one or more objects or arrays still open.
Because the set is small, you can handle it with a short function that tracks whether you are inside a string and which brackets are open, then trims and closes whatever is dangling. That is far more reliable than trying to patch the text with regexes alone, because a regex cannot tell a brace inside a string from a brace in the structure.
A small tolerant parser
The function below walks the text once to learn whether the cut landed inside a string and which brackets are still open. It then repairs the tail and calls the normal JSON.parse. If the repair still produces invalid JSON, it returns undefined and you simply keep showing the previous good value.
function parsePartialJson(text) {
const stack = [];
let inString = false, escaped = false;
let out = text;
for (let i = 0; i < text.length; i++) {
const c = text[i];
if (inString) {
if (escaped) escaped = false;
else if (c === "\\") escaped = true;
else if (c === '"') inString = false;
continue;
}
if (c === '"') inString = true;
else if (c === "{") stack.push("}");
else if (c === "[") stack.push("]");
else if (c === "}" || c === "]") stack.pop();
}
if (inString) {
if (escaped) out = out.slice(0, -1); // dangling backslash
out = out.replace(/\\u[0-9a-fA-F]{0,3}$/, ""); // incomplete \u escape
out += '"';
}
out = out.replace(/[\s,]+$/, ""); // trailing comma
if (/:$/.test(out)) out += "null"; // key with no value yet
// dangling key with no colon, e.g. {"a":1,"b"
if (/[{,]\s*"(?:[^"\\]|\\.)*"$/.test(out) && stack[stack.length - 1] === "}") {
out = out.replace(/,?\s*"(?:[^"\\]|\\.)*"$/, "");
}
// partial literal or number
out = out.replace(/(?<=[:\[,]\s*)(t|tr|tru|f|fa|fal|fals|n|nu|nul)$/, "null");
out = out.replace(/(?<=[:\[,]\s*)-$/, "null");
out = out.replace(/(\d)\.$/, "$1").replace(/(\d)[eE][+-]?$/, "$1");
out += stack.reverse().join("");
try { return JSON.parse(out); } catch { return undefined; }
}
Two details are worth calling out. First, the number repair only strips a trailing dot or exponent after a digit. An earlier version of this idea stripped any trailing e, which silently broke the literals true and false, because both end in the letter e. Second, a number that is still growing, like 12, parses as 12 and may later become 123. That is fine for display, but it is a good reason to treat partial values as provisional.
Test it at every cut point
The most useful test for this kind of parser is brutally simple: take a complete, valid JSON document that includes escaped quotes, a backslash, a non-ASCII character, negative and exponent numbers, booleans, null, nested arrays, and nested objects. Then parse every prefix of it, from one character up to the full length, and assert that none of them returns undefined and that the full text round-trips to the original.
const full = JSON.stringify({ title: 'Hi "there" é\\n', tags: ['a', 'b'], score: -12.5e1, ok: true, nested: { x: null, y: [1, 2, { z: 'end' }] }, done: false })
for (let i = 1; i <= full.length; i++) {
if (parsePartialJson(full.slice(0, i)) === undefined) throw new Error('failed at ' + i)
}
This kind of exhaustive prefix test is what caught the true and false bug above. It takes a few lines and covers every chunk boundary your network or the provider's streaming layer could ever produce, including boundaries in the middle of a multi-byte character or an escape sequence.
Wiring it to a stream
Most provider SDKs deliver text deltas or, for tool calls, argument deltas as strings. Accumulate them in a buffer, and after each delta, run the tolerant parser and hand the result to your UI state.
let buffer = ''
let latest = {}
for await (const delta of stream) {
buffer += delta
const parsed = parsePartialJson(buffer)
if (parsed !== undefined) {
latest = parsed
render(latest) // display only
}
}
const final = JSON.parse(buffer) // strict parse once the stream ends
validate(final) // schema check before anything else uses it
Notice the strict JSON.parse at the end. The tolerant parser is a display tool. The final strict parse and a schema validation are the only things that should gate real behavior.
Ask for fields in the order you want to show them
Streaming JSON renders top to bottom, so the order of fields in your schema is part of your UX. If the model writes a long explanation field before a short status field, the user waits through the explanation to see the status. Put the small, high-value fields first, such as a title, a category, or a yes or no decision, and put long free text last. If your provider supports structured output with a schema, define the properties in that display order, and say so in the prompt as well, since not every provider guarantees property order.
If one field depends on another, such as a summary that should reflect a list of findings, put the findings first. Models write more accurate summaries after the details exist in their own output.
Never act on partial data
This is the rule that matters most. A partial parse can look complete when it is not. A string value is missing its last words. A list shows two of five items. An amount reads 12 while the model is still writing 12.50. Everything the tolerant parser returns should be treated as a preview.
That has concrete consequences:
- Tool calls: do not execute a tool from streamed arguments. Wait for the call to finish, parse strictly, validate against the tool's schema, and only then run it. A half-written file path or a truncated SQL string can do real damage.
- Buttons and forms: keep submit and confirm controls disabled until the stream ends and validation passes.
- Analytics and logging: record the final validated object, not intermediate snapshots, or your numbers will be full of fragments.
- Side effects: sending an email, writing to a database, or charging a card belongs after the strict parse, ideally with an idempotency key, in case the stream is retried.
UI details that make partial rendering feel right
Showing raw partial values can look jumpy. A few small choices help:
- Mark in-progress fields. Add a subtle cursor or a muted style to the last string value so users understand it is still being written.
- Keep list items stable. Key array items by index and avoid reordering while streaming, or the layout will jump as items arrive.
- Skip fields that are still
nullfrom repair. The parser fills a key with no value yet asnull. Show a placeholder instead of the word "null." - Throttle renders. If deltas arrive faster than the screen refreshes, update state at most once per animation frame. Parsing a few kilobytes per chunk is cheap, but re-rendering a large component tree on every token is not.
Handling failures mid-stream
Streams can end early: the connection drops, the user cancels, or the model hits its output token limit and stops mid-object. In those cases the strict parse at the end will fail. Decide ahead of time what you do:
- If the user cancelled, discard the partial result and do not run any side effects.
- If the model hit its length limit, the finish reason from the provider will usually say so. Treat that as a failure, raise the limit or shorten the schema, and retry. Do not quietly ship the repaired partial as if it were complete.
- If the final text is complete but fails schema validation, retry once with the validation error included in the prompt, then show a clear error if it fails again.
It is tempting to use the repaired partial object as the final answer when the stream breaks. Resist that. The repair step exists to make previews possible, and the missing data is exactly the data you cannot see.
When to use a library instead
The function above is intentionally small and good enough for display. If you need stricter behavior, such as exact handling of duplicate keys, very large payloads, or a streaming parser that emits events instead of reparsing the whole buffer, consider a dedicated incremental JSON parser for your language. Reparsing the full buffer on every delta is quadratic in the response length. For typical responses of a few kilobytes that is negligible, but if you stream tens of kilobytes of JSON, measure it in your own app before deciding.
A short checklist
- Accumulate deltas in a buffer and parse tolerantly for display only.
- Test the parser against every prefix of a document that includes escapes, numbers, literals, and nesting.
- Order schema fields so the most useful ones stream first.
- Run the strict parse and schema validation once the stream ends, and gate every side effect on them.
- Treat early termination, cancellation, and length cutoffs as explicit failure paths.
- Throttle UI updates and show placeholders for fields still in progress.
With these pieces, users see structured results appear within the first moments of a response, and your backend still only acts on data that is complete and validated.
Comments
Post a Comment