GEMINI LABJP
AGENT — Managed Agents enter public preview in the Gemini API, running autonomous, stateful agents in isolated Google-hosted Linux sandboxesSEARCH — 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 playbackMODEL — Gemini 3.5 Flash reaches GA and now powers gemini-flash-latestDEPRECATION — Image generation models are scheduled to shut down on August 17, 2026; plan your migrationENTERPRISE — Gemini 3.5 Flash is generally available across the Global, US, and EU regions on Gemini EnterpriseAGENT — Managed Agents enter public preview in the Gemini API, running autonomous, stateful agents in isolated Google-hosted Linux sandboxesSEARCH — 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 playbackMODEL — Gemini 3.5 Flash reaches GA and now powers gemini-flash-latestDEPRECATION — Image generation models are scheduled to shut down on August 17, 2026; plan your migrationENTERPRISE — Gemini 3.5 Flash is generally available across the Global, US, and EU regions on Gemini Enterprise
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-api267streaming27structured-output21indie-dev42production135

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-07-07
Designing So the Next Shutdown Notice Doesn't Cost You an Afternoon: Isolating Gemini Behind a Single Port
The morning an image model shutdown notice landed, I couldn't say where my app touched that model. This is the design I use now: collapse Gemini dependencies into one port, with fallback and a CI deadline guard, shown as working code.
API / SDK2026-06-25
Gemini API × TypeScript Type-Safe AI Application Architecture — Integrating Zod Schemas, Structured Output, and Streaming
Learn how to build type-safe AI applications with the Gemini API and TypeScript. This guide covers Zod validation, Structured Output, streaming pipelines, and robust error handling for production architectures.
Dev Tools2026-06-02
A Lightweight Gemini Backend with Bun and Hono — Reclaiming the Small Tools of Indie Development
Has your Node and Express Gemini backend grown heavy with dependencies and build times? Here is how I moved one to Bun and Hono — folding streaming, rate limiting, cost caps, testing, and self-hosting into a single light runtime — along with the pitfalls I hit in production.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links
See all →