●AGENT — Managed Agents enter public preview in the Gemini API, running autonomous, stateful agents in isolated Google-hosted Linux sandboxes●SEARCH — File Search now supports multimodal search, natively embedding and searching images with gemini-embedding-2 (GA)●TTS — gemini-3.1-flash-tts-preview now streams speech generation for lower-latency playback●MODEL — Gemini 3.5 Flash reaches GA and now powers gemini-flash-latest●DEPRECATION — Image generation models are scheduled to shut down on August 17, 2026; plan your migration●ENTERPRISE — Gemini 3.5 Flash is generally available across the Global, US, and EU regions on Gemini Enterprise●AGENT — Managed Agents enter public preview in the Gemini API, running autonomous, stateful agents in isolated Google-hosted Linux sandboxes●SEARCH — File Search now supports multimodal search, natively embedding and searching images with gemini-embedding-2 (GA)●TTS — gemini-3.1-flash-tts-preview now streams speech generation for lower-latency playback●MODEL — Gemini 3.5 Flash reaches GA and now powers gemini-flash-latest●DEPRECATION — Image generation models are scheduled to shut down on August 17, 2026; plan your migration●ENTERPRISE — Gemini 3.5 Flash is generally available across the Global, US, and EU regions on Gemini Enterprise
Render Structured Output Field by Field as It Streams: Safe Partial JSON Parsing
With responseSchema streaming, the screen stays blank until the JSON closes. This walks through a partial parser that safely completes unclosed JSON, plus anti-flicker fencing that never lets a field move backward, and shows how time-to-first-field dropped from about 2.4s to 0.4s in practice.
I added a card to my own dashboard that summarizes AdMob's daily report. A common setup: hand Gemini the numbers, ask for a responseSchema with a headline, key points, recommended actions, and a score.
It worked. But after I clicked, the screen sat blank for a few seconds. Tokens were arriving behind the scenes, yet JSON.parse kept throwing until the final } closed the object, so nothing could be drawn.
I was streaming, but it felt exactly like a single blocking fetch. Worse, really, because "I clicked and nothing happened" reads as broken.
This article turns that blank stretch into a few hundred milliseconds by filling in fields the moment each one is settled. Working code included.
Why waiting for the close hurts
When you stream structured output, the model emits JSON from the top, a little at a time. What arrives is a run of fragments like these.
Arrival stage
Buffer contents (sketch)
JSON.parse
Early
{"title":"Battery-saving
fails
Middle
{"title":"Battery-saving 12","summary":"Dark
fails
Late
{...,"score":87}
succeeds
Standard JSON.parse tolerates nothing unclosed. So until the whole object closes at the end, we can extract nothing.
Yet look closely: title is already final in the early stage. We have something worth showing, and we throw it away over a single unclosed brace. That is the loss.
What we want is simple. Each time a chunk lands, safely pull out only the leading fields that are already settled, and stream them into the UI in order.
Safely completing an unclosed buffer
The approach: parse the buffer as-is, and if that fails, add the minimum set of closers and parse again. Count the open strings, objects, and arrays, then append the missing brackets.
Any half-written trailing value (a number mid-type, or a key with no value yet) is a source of jitter, so we drop it. The goal is to show only what is finished.
// Close open structures, drop the half-written tail, produce "what we know so far"function completePartial(buf: string): string { const stack: string[] = []; let inStr = false; let esc = false; let out = buf; for (let i = 0; i < buf.length; i++) { const c = buf[i]; if (inStr) { if (esc) esc = false; else if (c === "\\") esc = true; else if (c === '"') inStr = false; continue; } if (c === '"') inStr = true; else if (c === "{" || c === "[") stack.push(c); else if (c === "}" || c === "]") stack.pop(); } if (inStr) out += '"'; // close an open string out = out .replace(/,\s*$/, "") // drop a trailing comma .replace(/:\s*$/, ": null") // fill a valueless key with null .replace(/([0-9])\.$/, "$1"); // cut a dangling decimal point for (let i = stack.length - 1; i >= 0; i--) { out += stack[i] === "{" ? "}" : "]"; } return out;}export function safeParse(buf: string): unknown | null { try { return JSON.parse(buf); } catch { /* not closed yet */ } try { return JSON.parse(completePartial(buf)); } catch { return null; }}
The crux of completePartial is not counting anything inside a string. Even if a value contains a brace like "{", the inStr flag ignores it. Forget this, and the completion breaks the instant your text contains a bracket.
Now wire it to Gemini's stream. On every chunk, append to the buffer and pull out "what we know so far" with safeParse.
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Take home a working TypeScript function that safely completes and parses an unclosed JSON buffer on every chunk
✦Get a fencing implementation that stops a displayed field from shrinking or flickering as later chunks arrive
✦Follow how propertyOrdering plus partial parsing cut time-to-first-field from about 2.4s to 0.4s on a real card
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Partial parsing has a trap you will always hit if you build it naively: backflow.
When summary has only partly arrived, completePartial closes the string, so a fragment like "Dark-toned" flashes on screen for a moment. The next chunk extends it to "Dark-toned wallpapers, collected", and the display rewrites itself. That is your flicker.
Nastier still: depending on completion timing, a value can briefly be shorter than the previous chunk. When characters that should grow appear to shrink, the reader reads "broken."
The fix is fencing. A settled field is never touched again; a growing field only ever updates in the lengthening direction. Hold those two rules.
// Freeze what's settled; allow only forward growth for the restfunction fencedEmitter(onField: (k: string, v: unknown) => void) { const settled = new Set<string>(); const lastLen: Record<string, number> = {}; return (obj: Record<string, unknown>) => { const keys = Object.keys(obj); for (let i = 0; i < keys.length; i++) { const k = keys[i]; if (settled.has(k)) continue; const v = obj[k]; if (v === null || v === undefined) continue; // the next key is visible => this field is already closed const isSettled = i < keys.length - 1; if (typeof v === "string") { if ((lastLen[k] ?? 0) > v.length && !isSettled) continue; // drop shrinking updates lastLen[k] = v.length; } onField(k, v); if (isSettled) settled.add(k); } };}
"If the next key is visible, the field before it is closed" is the safety valve here. Fixing the order with propertyOrdering keeps that assumption from breaking.
Designing arrival order with propertyOrdering
propertyOrdering is not display decoration. It is a design lever for which field you want to show first.
For a summary card, the reader wants the headline and key points first. The numeric score and the array of actions can wait. So put the short, quickly-settled fields up front and the long array at the back.
Ordering
What fills first
Feel
title -> summary -> actions -> score
headline appears fastest
the click feels answered at once
actions -> score -> title -> summary
no headline until the array closes
the opening looks unresponsive
Arrays and nested objects close late and are harder to complete. Simply moving the light, short fields to the front visibly shrinks time-to-first-field.
Before / After: time to the first field
On my dashboard's summary card, I measured the time from click to the first meaningful field. I called gemini-flash-latest from my Mac, ran the same input 20 times, and took the median.
Method
First field shown
All fields settled
Fetch fully, then parse (Before)
~2.4s
~2.4s
Partial parse + fencing (After)
~0.4s
~2.5s
The time to fully settle barely changes. If anything it grew by tens of milliseconds for the completion work. What changed the feel was that the headline appears right after the click.
The chunk breakdown shows it too. For the same response arriving in 15 chunks, here is which chunk first made each settled field readable.
Field
First readable chunk
Position in the stream
title
2 / 15
13%
summary
5 / 15
33%
actions
9 / 15
60%
score
15 / 15
100%
A blocking fetch can draw its first pixel only at the bottom of that table, at 100%. Partial parsing can start drawing at 13%. That is the same response, shown with almost no wait.
Pitfalls that bite in production
After running this for a few months, here is where I actually stumbled.
Do not show a half-typed number. If you display 8 while "score": 8 is arriving, then it rewrites to 87, the reader misreads the figure. For numbers only, it was safest to hold display until you can confirm the field is closed by a visible next key. That is why fencedEmitter waits on isSettled.
Do not close in the middle of an escape. Closing a string the instant a trailing \ appears breaks \". The esc flag in completePartial exists for that single character.
Always wire up cancellation. If the user leaves but the stream keeps running, only the billing advances. Pass an AbortController to the stream and cut it firmly on unmount.
const controller = new AbortController();// ...generateContentStream({ ..., abortSignal: controller.signal })// on leavecontroller.abort();
Never log the completed approximation. Completed JSON is a temporary guess. For persistence and audit logs, always use the fully-closed final buffer. Store the in-flight version and you will later confuse yourself with "broken JSON."
Folding it into an indie app
I extended this pattern beyond the summary card, into the metadata-generation preview in my wallpaper app. The title and tags appear first, and the description flows in afterward. A screen that waited on generation became a screen that fills in.
No large redesign is needed. You already stream, so all you add is two functions, safeParse and fencedEmitter. Add one line of propertyOrdering to your responseSchema and reorder by what you want shown first. That is what shrinks time-to-first-field.
As a next step, pick the one screen in your app that most "looks unresponsive when tapped," and drop these two functions into it. The feel of a click answering back lands harder than the numbers do.
Thanks for reading, and I hope this helps in your own build.
Share
Thank You for Reading
Gemini Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.