Last Tuesday I pointed a small chart dashboard at MonkeyCode's free server and asked the model to stream a JSON object with the data I needed. The first chunk arrived, my trusty JSON.parse threw SyntaxError: Unexpected end of JSON input, and the entire dashboard went blank while the network tab kept delivering perfectly valid bytes. How many times have you blamed the network when the parser was the real problem? The model was innocent, the server was innocent, and my parser was the only guilty party in the whole chain.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
That moment taught me a lesson I keep relearning: streaming changes the contract of every API you touch. JSON.parse expects a complete document, but a stream delivers fragments, and each fragment is a valid string that happens to be an invalid JSON document. The browser's Response.json() method suffers from the same blindness, because it buffers the entire body before parsing, which defeats the entire purpose of streaming in the first place.
The debugging path
Before I blamed the parser, I blamed everything else. My debugging ritual went through three stages, and each one eliminated a suspect while pointing at the real culprit.
First, I checked the server with curl to confirm the endpoint was actually streaming JSON:
curl -N -X POST https://your-free-server.example/api/chat/stream \
-H "Content-Type: application/json" \
-d '{"prompt":"Return JSON: {\"labels\":[\"a\",\"b\"],\"values\":[1,2]}"}'
The -N flag disables buffering, and the output showed tokens arriving one by one. The server was streaming, and the response was valid JSON when complete.
Second, I logged every chunk in the browser to see what the parser was actually receiving:
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
console.log("chunk:", JSON.stringify(chunk));
}
The console showed fragments like {"labels":["a" and ,"b"],"values":[1,2]}. Each fragment was incomplete, and JSON.parse threw on every single one. That was the moment I realized the parser was not the victim; it was the villain.
Third, I checked whether the model could stream complete JSON objects instead of fragments. Some models support a JSON mode that emits one complete object per chunk, but my prompt did not request that, and the free model did not offer it by default. I could not rely on the model changing its behavior, so I had to change mine.
Strategy 1: Buffer and retry
The simplest fix for a single JSON object streaming across many chunks is to buffer everything and retry parsing after each chunk. This works because JSON.parse is idempotent: it either succeeds with a complete document or throws on an incomplete one.
class BufferedJsonParser {
#buffer = "";
push(chunk) {
this.#buffer += chunk;
try {
const value = JSON.parse(this.#buffer);
this.#buffer = "";
return { done: true, value };
} catch {
return { done: false, value: null };
}
}
}
The trade-off is performance: parsing the entire buffer on every chunk turns an O(n) operation into O(n²) for a stream with many chunks. For a typical chat response of a few thousand tokens, this is negligible, but for a 100,000-token stream, you will feel the quadratic cost.
Strategy 2: Line-based parsing
If the model returns JSON Lines (JSONL), where each line is a complete JSON object, the parser becomes a simple split-and-parse operation. This is the format I now request from models when I control the prompt.
class JsonlParser {
#buffer = "";
push(chunk) {
this.#buffer += chunk;
const lines = this.#buffer.split("\n");
this.#buffer = lines.pop() ?? "";
return lines
.filter((line) => line.trim())
.map((line) => JSON.parse(line));
}
}
The beauty of JSONL is that each line stands alone, so partial lines wait in the buffer while complete lines flow straight to the UI. This pattern also gives you natural progress events: every parsed line is a unit of work done, which you can announce or render incrementally.
Strategy 3: Field extraction
Sometimes you do not need the whole object; you need one field, like a status message or a partial answer. In that case, a targeted extraction beats a full parse.
function extractStringField(chunk, fieldName) {
const pattern = new RegExp(`"${fieldName}"\\s*:\\s*"((?:[^"\\\\]|\\\\.)*)"`);
const match = chunk.match(pattern);
return match ? JSON.parse(`"${match[1]}"`) : null;
}
This is a hack, and I use it only for quick prototypes, because it breaks on nested objects and arrays. But for a simple "status": "thinking" field, it gives you real-time updates without waiting for the full document.
Accessibility of partial data
A parser that waits for the complete object creates a silent gap: the user sees nothing while tokens accumulate in the buffer. Screen reader users experience the same silence, and a silent interface feels broken even when it is working correctly.
The fix is to announce partial progress. When the parser returns { done: false }, update a live region with a meaningful status like "Receiving data" or "Loading chart data". When the parser finally returns { done: true }, announce the result and move focus to the rendered output.
<div id="status" aria-live="polite">Receiving data…</div>
The key is to avoid announcing every chunk, which would spam the screen reader. Announce state transitions, not byte counts: "Receiving data", "Data complete", "Error parsing response". That gives screen reader users the same mental model that sighted users get from a progress bar.
Verification matrix
Before you ship any streaming JSON UI, run this test matrix:
| Scenario | Input | Expected result |
|---|---|---|
| Single object, small chunks |
{"a":1} split as {"a", :1}
|
Parsed once complete |
| Single object, large chunks | Full object in one chunk | Parsed immediately |
| JSONL, partial last line | {"a":1}\n{"b" |
First line parsed, second buffered |
| Nested object | {"a":{"b":1}} |
Parsed once complete |
| Escaped quotes in strings | {"a":"he said \"hi\""} |
Parsed without corruption |
| Malformed JSON | {"a":} |
Error surfaced, not swallowed |
Each row corresponds to a real failure mode I have hit, and each one deserves a test in your CI pipeline or at least a manual check before release.
Limitations and who should skip this
Incremental parsing is not a universal win. If your model returns a single massive JSON object and your UI cannot render anything until the object is complete, buffering is the only option, and the O(n²) cost is unavoidable. If your model supports a native JSON mode that emits complete objects, use that instead of writing a parser.
The free server is a prototyping environment, not a production SLA, and the free model's token quota of 10 million as of August 2026 is a budget to measure, not a guarantee. Check the project documentation before relying on either number, because quotas change without ceremony. If you want to experiment with streaming JSON parsing without paying for a production account, MonkeyCode's free server is a convenient playground — just bring your own parser, because JSON.parse will not save you.
Top comments (0)