◉GEMINI LABJP
●ARGON — gemini-4-argon is generally available as of 10/13, aimed at complex coding, agents, math and science●DEEP RES — deep-research-pro-preview-12-2025 shuts down 10/23, 6 days left. Move to deep-research-preview-04-2026●NANO — gemini-nano-banana-2.1 is GA (10/06). gemini-3.1-flash-image is deprecated with no shutdown date yet●MCP — One write-up cut an 85,000-token tool result to 310 tokens by wrapping it in an ADK FunctionTool●FLASH — gemini-3.7-flash and 3.5-flash now route automatically to newer Flash models (10/08)●NEW — Stop Re-Reading Every Crash Report: Gre…●ARGON — gemini-4-argon is generally available as of 10/13, aimed at complex coding, agents, math and science●DEEP RES — deep-research-pro-preview-12-2025 shuts down 10/23, 6 days left. Move to deep-research-preview-04-2026●NANO — gemini-nano-banana-2.1 is GA (10/06). gemini-3.1-flash-image is deprecated with no shutdown date yet●MCP — One write-up cut an 85,000-token tool result to 310 tokens by wrapping it in an ADK FunctionTool●FLASH — gemini-3.7-flash and 3.5-flash now route automatically to newer Flash models (10/08)●NEW — Stop Re-Reading Every Crash Report: Gre…
Articles/Advanced
⬡ Advanced/2026-07-07Advanced

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.

gemini-api285streaming28structured-output28indie-dev48production140

✦ Premium Article

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 stageBuffer contents (sketch)JSON.parse
Early{"title":"Battery-savingfails
Middle{"title":"Battery-saving 12","summary":"Darkfails
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.

import { GoogleGenAI } from "@google/genai";
 
const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY! });
 
async function streamCard(prompt: string, onField: (k: string, v: unknown) => void) {
  const stream = await ai.models.generateContentStream({
    model: "gemini-flash-latest",
    contents: prompt,
    config: {
      responseMimeType: "application/json",
      responseSchema: {
        type: "object",
        properties: {
          title:   { type: "string" },
          summary: { type: "string" },
          actions: { type: "array", items: { type: "string" } },
          score:   { type: "integer" },
        },
        propertyOrdering: ["title", "summary", "actions", "score"],
        required: ["title", "summary", "actions", "score"],
      },
    },
  });
 
  let buf = "";
  const emitter = fencedEmitter(onField);
  for await (const chunk of stream) {
    buf += chunk.text ?? "";
    const obj = safeParse(buf);
    if (obj && typeof obj === "object") emitter(obj as Record<string, unknown>);
  }
}
✦

Thank you for reading this far.

Continue Reading

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.

or
Unlock all articles with Membership →
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.

  • ✦Copy-paste ready implementation code
  • ✦New advanced guides published daily
  • ✦$5/mo or $10 for lifetime access
View Membership →

Related Articles

⬡ Advanced2026-04-23
Defending Gemini API Apps from Prompt Injection: A Multi-Layer Production Architecture
A four-layer prompt injection defense for Gemini apps: sanitized input, hardened prompts, structured output, and a moderator LLM — with runnable Python.
⬡ Advanced2026-04-11
Connecting Gemini Agents with Google's A2A Protocol: Design, Implementation, and Cloud Run Operations
Build cross-framework agent coordination with the Agent2Agent (A2A) protocol and Gemini API. Agent card design, measured comparison of three coordination patterns, and the operational details that matter on Cloud Run.
◈ API / SDK2026-08-22
Once You Pass Twenty Mediation Groups, How Do You Find the Setting That Went Missing?
As ad mediation groups multiply, missing sources and type drift accumulate quietly. Here is the split I settled on: normalize the settings into one matrix, let code confirm the gaps, and send Gemini only the cells that need judgment.