Two years ago I wrote a partial JSON parser for one job: pulling the response field out of a half-received LLM reply so the text could appear on screen while the rest of the JSON was still streaming. The post ended with a ninety-line function in a code block and a gist to copy. The streaming post that followed it leaned on the same function, and each app I used it in got its own copy with the field name changed, which is the sign that a code block should have been a package.
So it is one now.
npm install fenced-json
It has no dependencies, runs in Node and the browser, ships with tests, and there is a playground where you can paste a response, replay it a few characters at a time, and watch the fields appear. This post is about what had to change when the function stopped being specific to my app.
What the old one did
The 2024 version made four assumptions, and said so:
I only care about one field (
response). The structure is known and flat. The value I need is always a string. I'm already handling the stream myself.
Given those, the implementation was indexOf('"response"'), skip to the opening quote, then walk characters handling escapes until the closing quote or the end of the text. The post described it as "scrappy, specific to my use case" and it was. It also listed what it did not handle: nested objects, arrays as the streaming target, unknown schemas, non-string values.
A general version has to drop every one of those assumptions, and each one turned out to hide a design decision rather than just more code.
Where does the JSON start
The first problem is not JSON at all. Models wrap structured output in a markdown fence because the system prompt told them to, and some models add a line of prose first ("Sure, here is the structured answer:") no matter what the prompt says. The old code searched for
```json
and chopped. That works until the fence has not fully arrived.
Consider the text so far being Sure!\n`json. Is the JSON about to start? Probably. But the parser cannot tell the language tag from the start of the content until the newline arrives, so the honest answer is "nothing yet". The same goes for Sure!\n on its own: a fence may be coming, or bare JSON may be coming, or the model may be about to ramble. The package returns a state of empty for all of these and parses nothing.
Once there is a fence followed by a newline, parsing starts after it. When there is no fence, parsing starts at the first { or [. That second rule is a compromise: a brace inside the preamble would trip it. In practice preamble before bare JSON is rare, because models that add preamble tend to also add fences, and you can turn the rule off with fences: false if you know the text is bare JSON and want a preamble to be an error.
After the closing brace, whatever follows (the closing fence, a "Let me know if you need anything else") goes into rest, so you can show it or drop it.
A half string is a string, a half number is a lie
The old parser only streamed strings, and a prefix of a string is useful: "Here's what I fou" is readable and the user sees it grow. Generalising to every JSON type forced the question of what a prefix of each type means.
| Cut inside a | What you get | Why |
|---|---|---|
| string | the prefix so far | a partial sentence is still readable |
| escape sequence | the string without the half escape |
\u00 is not a character yet |
| number | nothing, until a delimiter arrives |
12 may be about to become 125
|
true, false, null
|
the completed literal | only one literal starts with each letter |
| object key | nothing, the key is left out | a key with no value is not a field |
| array or object | the container, closed | with whatever children have arrived |
The number rule is the one I went back and forth on. Every other partial-JSON library I tried returns 12 for {"n": 12 at the end of the text, and for a price field that is wrong by a factor of ten for the duration of one chunk. A string prefix is a true statement about the final string. A number prefix is not a true statement about the final number. So the default drops it, and partialNumbers: 'keep' brings it back for the cases where a flickering digit is acceptable.
Strings get the opposite treatment with the same reasoning in reverse. The default keeps the prefix because that is the whole point for display text. But if the string is an enum or an identifier you will switch on, a prefix is dangerous: "lang": "e" is not "en". For those, partialStrings: 'drop' holds every string back until its closing quote. It is a per-call option rather than per-field because the parser does not know your schema; you can call it twice with different options if you need both behaviours, and at the sizes involved that costs nothing.
Which field is streaming
With one field there was no question of which field was still arriving. With a whole tree there is, and it matters more than I expected. The suggestions list in my own app should render once it is complete, not item by item with half a name on the last one. The response text should render as it grows. Both come from the same parse result, so the result has to say where the cut is.
The parser tracks its path as it descends and records it the first time it runs out of text. For {"a": "x", "b": [1, {"c": "d the streaming path is ["b", 1, "c"]. For {"a": "x", "b": [1, {"c it is ["b", 1], because the inner object is open and its next key is incomplete. When the document is complete the path is null.
From that path, isComplete(result, ["b", 0]) is a one-line check: the value at the path exists, and the streaming path does not pass through it. A field that has not arrived yet is not complete either, which is the case the obvious implementation gets wrong.
`ts
const r = parsePartial('{"response": "done", "items": [{"id": 1}, {"id": 2, "name": "Lu');
r.streaming // ['items', 1, 'name']
isComplete(r, ['response']) // true
isComplete(r, ['items', 0]) // true
isComplete(r, ['items', 1]) // false
isComplete(r, ['language']) // false, not arrived
`
Incomplete is not invalid
The old function returned null for "nothing to show yet" and there was no way to say "this will never parse". Models do emit broken JSON occasionally: a trailing comma, a single-quoted key, an unescaped quote. A general parser has to distinguish "not yet" from "never", because the UI response differs. For "not yet" you wait. For "never" you stop waiting and fall back to showing the raw text.
The result's state is one of empty, partial, complete or invalid, and the rule the tests enforce is that every prefix of a valid document is one of the first three. The test suite takes a sample document and parses all of its prefixes, asserting that none is invalid and that every value returned is a sub-tree of the final value (every string a prefix of the final string, every array no longer than the final array, every key present in the final object). That one property test is also what settled the partial-number rule. I had written the obvious expectation, that [1, 2 parses to [1, 2], and the test failed because the trailing 2 had been dropped. Writing out why the drop was right is where the table above came from. The escape-sequence cases, \u split across two chunks among them, are the ones the 2024 version would have got wrong, so they each have their own test.
invalid carries the offset where parsing failed, which turns out to be useful for prompt debugging. If the model keeps producing bad JSON at the same offset, you learn something about your schema.
Re-parse, do not be clever
The obvious architecture for a streaming parser is incremental: keep the state machine between chunks and only consume new characters. The Vercel AI SDK's fixJson is a single-pass scanner of that sort. I started down that road and stopped, because the incremental version needs the partially built tree to be mutable in place, and then the value you handed the caller last chunk changes under them. Immutable results mean a fresh tree each time, and a fresh tree means you may as well re-parse.
Re-parsing from the start on every chunk is quadratic in the response length. For LLM responses that does not matter, and I measured rather than asserted it. The benchmark feeds a pretty-printed fenced document in twenty-character chunks and times the whole stream, taking the best of several runs.
| Document | Chunks | Total | Per chunk |
|---|---|---|---|
| 1 KB | 61 | 0.2 ms | 3 µs |
| 4 KB | 215 | 2.3 ms | 11 µs |
| 16 KB | 826 | 33 ms | 40 µs |
| 64 KB | 3,286 | 523 ms | 159 µs |
Node 23.11 on an M-series laptop. A typical chat response with a few suggestions attached is under 4 KB. Even at 64 KB the per-chunk cost is well under a frame. If your structured output runs to hundreds of kilobytes, you want an incremental parser and this is not it, and the README says so.
The API
Three exports do the work.
`ts
import { parsePartial, PartialJson, isComplete } from 'fenced-json';
type Reply = {
response: string;
language: string;
items: { id: string; name: string }[];
};
const json = new PartialJson();
for await (const chunk of stream) {
const r = json.push(chunk);
showText(r.value?.response ?? '');
if (isComplete(r, ['items'])) showItems(r.value!.items!);
}
`
value is typed as DeepPartial<Reply>: every field optional, all the way down, because that is what it is until the stream finishes. The compiler then makes you handle the undefined case at every access, which is tedious for about ten minutes and then saves you from the bug where the suggestions render with no names.
The 2024 usage pattern maps onto this almost line for line. extractResponseFromPartialJson(accumulated) becomes json.push(chunk).value?.response, and tryParseCompleteJson becomes checking state === 'complete'. The streaming post's React Native app calls the old function from its chunk handler, one layer above the transport, and swapping it for the new one is a two-line change there too. That app's copy had also grown two more suggestion fields and a renamed one, which is the drift that a package stops.
What the other packages do
There are several partial JSON parsers on npm and I tried four of them against the same inputs before deciding to publish another. The fenced case is the one that separates them.
| Input | partial-json | best-effort-json-parser | incomplete-json-parser | jsonrepair |
|---|---|---|---|---|
`json\n{"response": "Hello th ` |
| throws | `{response: "Hello th"}` | throws | `{response: "Hello th"}` |
| `Sure! ...\n
json\n{"response": "Hi", "items": [{"id": 1, "na| throws |{response, items: [{id: 1}]}| throws | wraps the preamble and the object in an array |{"n": 12.
||{}|{n: 12}|{n: 12}|{n: 12}|{"s": "a\u00
||{s: "a"}| throws |{s: "a"}|{s: "a"}|{"response": "done", "lang
||{response: "done"}|{response: "done"}| addslang: null| addslang: null` |
Versions as of October 2026: partial-json 0.1.7, best-effort-json-parser 1.5.1, incomplete-json-parser 1.1.5, jsonrepair 3.15.0. The Vercel AI SDK's parsePartialJson is not in the table because it lives inside the ai package and expects bare JSON; it tries JSON.parse, then repairs with a scanner and tries again, and reports one of successful-parse, repaired-parse or failed-parse.
None of them tells you which field is still streaming, and none of them distinguishes a number that is finished from one that is not. Two of them handle fences. If you already depend on jsonrepair for its actual purpose, which is fixing broken JSON rather than completing truncated JSON, it does a reasonable job on this too, though the preamble handling will surprise you.
The playground
The playground loads the same dist/index.js the package ships, so what you see there is what you get from npm. Paste a response or use the sample, press play, and the right-hand pane shows the received text, the state, the streaming path and the parsed value with the live field highlighted. The slider lets you scrub to the exact character where a field appears, which is how I checked most of the rules in the table above. The options panel toggles the fence stripping and the two partial-value rules so you can see what each one does to the same text.
What it does not do
It parses bare JSON when no fence is present, where the app copies of the old function refused to parse anything outside a `
json
block. If you port from one of those and your preamble can contain a brace, passfences: false` or keep the fence in the prompt.
It does not repair invalid JSON. If the model emits a trailing comma the result is invalid, not a best guess, because guessing is a different job and jsonrepair already does it well. It does not validate against a schema; pass the complete value to whatever you use for that. It is not incremental, for the reasons above. And it tolerates one thing JSON.parse does not, which is raw newlines inside strings, because models produce them and rejecting a whole response over one is not helpful.
The source is a single file, shipped inside the package alongside the compiled output, and the tests are in the same style as the examples in this post. MIT licence, as before. If you built one of those variants from the gist, this should replace it without you having to think about escape sequences ever again.
Top comments (0)