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.
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>);
}
}