DEV Community

Cover image for JSON.parse throws on every token your LLM streams. Here's a 425-byte fix.
Manthan Kansagra
Manthan Kansagra

Posted on

JSON.parse throws on every token your LLM streams. Here's a 425-byte fix.

I was building a small app where an LLM returns a recipe as JSON and the UI fills in as the answer streams. Simple idea. It fell over immediately.

Here's what my handler actually saw between tokens:

{"title": "Pad Th
{"title": "Pad Thai", "ingr
{"title": "Pad Thai", "ingredients": ["rice noo
Enter fullscreen mode Exit fullscreen mode

JSON.parse throws on every one of those. Every single token, until the very last }.

So you have two bad options. Wait for the whole thing to finish — which throws away the reason you're streaming in the first place. Or write a regex that closes some brackets and hope. Mine broke on the first escaped quote.

I wanted a third option: on every chunk, give me the best value you can honestly get from what's arrived so far. Never throw. Never make things up.

That turned into a tiny library called SoFar. It's 425 bytes gzipped, has zero dependencies, and does exactly one thing.

A JSON document arriving token by token, with the parsed value updating beside it

What it does

import { parsePartialJSON } from "sofar-json";

parsePartialJSON('{"title": "Pad Th');
// → { title: "Pad Th" }

parsePartialJSON('{"title": "Pad Thai", "ingr');
// → { title: "Pad Thai" }

parsePartialJSON('{"title": "Pad Thai", "servings": 4,');
// → { title: "Pad Thai", servings: 4 }

parsePartialJSON('{"ok": tru');
// → {}

parsePartialJSON('');
// → undefined
Enter fullscreen mode Exit fullscreen mode

Notice the fourth one. tru is probably going to be true, but the buffer doesn't say that yet, so you get the last value that was actually complete. A partial string comes back as a string, because a prefix of a string is still a string. A partial number or literal doesn't get guessed.

For the streaming case there's a small stateful wrapper:

import { createJSONStream } from "sofar-json";

const stream = createJSONStream();

for await (const chunk of llmResponse) {
  const value = stream.feed(chunk);
  if (value !== undefined) render(value);
}

JSON.parse(stream.raw); // strict parse once it's done
Enter fullscreen mode Exit fullscreen mode

That's the whole API. Two functions.

How it works

I didn't want a second JSON parser. JSON.parse is fast, correct, and already in every runtime. I just needed to hand it something it could accept.

So the library scans the buffer once, left to right, keeping track of three things:

  1. Which containers are open — a stack of { and [
  2. Whether we're inside a string, and whether the last character was a backslash
  3. A list of safe cut points: positions where the JSON so far is structurally sound. Right after a string closes, right after a container opens or closes, right before a comma.

Each cut point stores a snapshot of the stack at that moment. Not a copy — the stack is kept as the string of closing brackets you'd need to append, innermost first, so a snapshot is just a reference. Rewinding later is a slice and a concat. No rescanning.

Then it tries two things:

Attempt 1. If we're inside a string, close it. Append the closers for every open container. JSON.parse. This handles almost everything: a string cut mid-word, an array mid-element, an object mid-value.

Attempt 2. If that fails — a dangling key like {"a": 1, "b, a trailing comma, a half-typed fals — walk the cut points backwards. Slice the buffer at each one, append that cut's stored closers, try again. The first one that parses wins.

If nothing parses, return undefined. Never throw.

There's a bit of extra care for escapes. A trailing \ gets dropped, a half-finished \u00e gets trimmed back to the backslash, but \\ at the end of a string is left alone because that's a complete escape. The scanner already knows which case it's in, so this costs nothing.

The whole thing is one pass, O(n), and on a 1.6 MB buffer it runs at about 1.3× the cost of a bare JSON.parse.

How it compares

I checked the alternatives with the same inputs before writing this. Sizes are esbuild + gzip.

Input SoFar partial-json best-effort-json-parser jsonrepair
Size 425 B 1.6 kB 1.9 kB 3.7 kB
'' undefined throws "" throws
}}} undefined throws returns "}}}" throws
{"ok": tru {} {ok: true} {ok: true} {ok: "tru"}
{"a":"x","ingr {a: "x"} {a: "x"} {a: "x"} {a: "x", ingr: null}

They're all fine libraries and I'd used a couple of them. What I wanted was different: never throw, never invent, and be small enough that I'd never think about it in a bundle. If your JSON has broken syntax — single quotes, comments, unquoted keys — jsonrepair is the right tool, and the two chain nicely.

What it won't do

  • It won't fix JSON that was never going to be valid.
  • It won't guess 4. is 4. That could be 4.5.
  • It won't give you diffs. You get a full value each call. At LLM token rates that's fine.

Try it

There's a playground where you can paste any JSON and scrub through it a character at a time. It shows the exact string handed to JSON.parse, which characters were synthesized, which were rewound past, and every cut point the scanner recorded:

https://acegikmo135.github.io/sofar/

npm install sofar-json
Enter fullscreen mode Exit fullscreen mode

Source, tests, and the fuzz test that feeds a document one character at a time are on GitHub: https://github.com/acegikmo135/sofar

If you've got a stream it handles badly, the most useful thing you can send me is the raw buffer as a string. It becomes a test case.

Top comments (0)