AI agents
Key points

Key points

  1. 1A character-level state machine tracks whether the stream is inside a string, an escape, a number or a container, so you can update a mutable object as each key and value completes.
  2. 2Render each field as soon as it closes, but hold values that must be atomic (IDs, enums, URLs) in a buffer until the full value arrives.
  3. 3Validate at key boundaries rather than per token, because most type errors are only decidable once a value has ended.
  4. 4On failure, restore the last known-good snapshot before retrying so a dropped stream cannot leave half-written state in your object.

Why JSON.parse cannot help you mid-stream

JSON.parse is all-or-nothing. It needs a balanced document before it returns anything, so until the closing brace arrives you have nothing to show (a write-up on streaming JSON parsers puts it plainly: partial input is not a state the standard parser models, it is "input error"). That is a fine contract for an HTTP body with a known content length. It is the wrong contract for a token stream, where the document is legitimately incomplete for most of its life.

Teams usually hit this in one of two ways. The first is to wait for the closing brace and parse once. That works, but the user stares at a spinner for the entire generation, and the perceived-latency cost is real: one measurement cited in that same write-up puts the improvement from progressive rendering at roughly 60 to 70 percent, not because the response got faster but because the waiting changed shape.

The second is worse. Teams keep buffering but call JSON.parse (or a repair library) on every chunk to "see what we have so far." That re-parses the whole prefix on every token, which is O(n²) work. The same write-up reports a naive implementation taking 16.7 seconds on a 12 KB document with final chunks costing 19 to 20 milliseconds each, against 43 milliseconds for a stateful incremental parser on the same workload. The naive version became unusable around the 5 KB mark. If you already have a repair step in your pipeline, it is the right tool for a dropped connection and the wrong tool for the hot path.

Before you write a parser, check whether the model is constrained in the first place. Constrained decoding masks invalid tokens at generation time so the output is valid by construction, and the ZeroEntropy explainer notes that parse-error rates of 1 to 5 percent are common when you rely on prompting alone (constrained decoding overview). If your provider supports schema-constrained output, turn it on. A partial parser is still needed for rendering, but it is no longer a correctness crutch.

The state machine at the center of everything

A partial JSON parser is not a relaxed parser. It is a state machine that explicitly models the open positions in the document: which strings are mid-character, which arrays are mid-element, which objects have an unbalanced brace (tianpan.co). JSON's grammar is small enough that you can enumerate the states by hand.

StateMeaningWhat ends it
VALUE_STARTPosition where a value may begin{, [, ", digit, -, t, f, n
IN_STRINGInside a string literal, no pending escapeUnescaped "
ESCAPEJust consumed a backslash inside a stringExactly one character (or u plus four hex digits)
IN_NUMBERConsuming digits, sign, decimal point, exponentAny character outside the number alphabet
IN_OBJECT_KEYInside a quoted keyClosing quote, then :
AFTER_VALUEA value just ended inside a container, or the closing bracket
DONETop-level value closedStream end

Two details trip people up. First, the escape state is not optional: "path": "C:\\temp" and "note": "line\nbreak" both contain a backslash that must not be treated as content, and a \u escape spans six characters. Second, numbers are greedy. You cannot know 12 has ended until you see a character that cannot continue it, which is why number values should only be committed when the delimiter arrives.

Here is a minimal character-level implementation. It is deliberately small; wire it into your own transport.

CONT = set("0123456789+-.eE")
LITERALS = {"t": "true", "f": "false", "n": "null"}

class StreamParser:
    def __init__(self):
        self.stack = []          # container frames
        self.state = "VALUE_START"
        self.buf = ""            # current token text
        self.pending_key = None
        self.root = None

    def push(self, chunk):
        for ch in chunk:
            self._step(ch)
        return self.snapshot()

    def _step(self, ch):
        s = self.state
        if s == "IN_STRING":
            self.buf += ch
            if ch == "\\":
                self.state = "ESCAPE"
            elif ch == '"':
                self._close_string()
            return
        if s == "ESCAPE":
            self.buf += ch
            self.state = "IN_STRING"
            return
        if s == "IN_NUMBER":
            if ch in CONT:
                self.buf += ch
                return
            self._commit_number()
            self._step(ch)
            return
        if s == "IN_LITERAL":
            self.buf += ch
            if self.buf in LITERALS.values():
                self._commit_literal()
            return
        # VALUE_START and container handling
        if ch.isspace() or ch == ",":
            return
        if ch == '"':
            self.state = "IN_STRING"
            self.buf = ""
        elif ch == "{":
            self._open("object")
        elif ch == "[":
            self._open("array")
        elif ch in LITERALS:
            self.state = "IN_LITERAL"
            self.buf = ch
        elif ch in CONT or ch.isdigit():
            self.state = "IN_NUMBER"
            self.buf = ch
        elif ch in "}]":
            self._close_container(ch)

    def snapshot(self):
        return self.root

The _open, _close_container, _close_string, _commit_number and _commit_literal methods attach to self.stack[-1] and, on the last close, set self.root. Keep them separate from _step so you can add validation hooks there without touching the state transitions.

The important invariant: snapshot() must never throw and must never return a value that is inconsistent with what has already been committed. That is the property that makes incremental rendering safe.

Render on close, buffer on atomicity

The single most useful decision rule is this: values that a user can read while growing are safe to render continuously; values that a machine consumes are not.

Field kindRender policyWhy
Prose, summaries, reasoning textStream characters as they arriveThe reader gains from seeing text fill in
Long string listsAppend items as each string closesEach item is independently meaningful
IDs, UUIDs, order numbersBuffer until the closing quoteHalf an ID is worse than no ID
Enums and status valuesBuffer, then validate against the allowed setA prefix like "con" is ambiguous between confirmed and cancelled
URLs, emails, file pathsBuffer until closeA partial URL can 404 or point at a live but wrong resource
Numbers and booleansCommit on delimiter12 may become 1230
Nested objects used as tool argumentsBuffer the whole objectExecuting a tool half-populated is a production incident

The last row is the one that bites hardest. A chat UI and a function-call argument want opposite behavior from the same stream: a chat UI probably wants partial strings, a function-call argument probably does not, because executing a tool with a half-typed argument is worse than waiting one more chunk (tianpan.co). Make that a per-field policy in your schema, not a global setting.

The practical way to implement it is to have the parser emit events rather than only a snapshot:

  • key_start(path) when a key's opening quote is consumed.
  • string_grow(path, delta) for each character inside a streamed string.
  • value_complete(path, value) when a string closes, a number is delimited, or a container closes.
  • container_close(path) when a brace or bracket balances the outermost frame.

Your renderer subscribes to string_grow for prose fields and to value_complete for everything else. This keeps the buffering policy in the view layer where it belongs.

If you would rather not maintain the machine, best-effort-json-parser follows exactly this contract: it returns partial data for incomplete input, so parse('[1, 2, {"a": "apple') yields [1, 2, { a: 'apple' }] (best-effort-json-parser docs). jsonchunk advertises the same shape with a typed object on every chunk (jsonchunk), and jsonriver is an incremental parser built specifically to feed UI refresh from LLM JSON, with an API that returns a series of partial values (JSON River on Hacker News). Note the author's own caveat there: jsonriver mutates existing values where possible rather than allocating new ones, so React or Lit memoization needs short-circuiting to notice updates.

Validate at key boundaries, not per token

Per-token validation is a tempting default and it is almost always wrong. Most checks are undecidable mid-token: you cannot reject "score": "hi as a type error before the closing quote, and you cannot reject a number as out of range until the delimiter arrives. Running a schema validator on every chunk also gives you the O(n²) problem in a different costume.

Validate when a value is decided: the moment its type is fixed and either the value is complete or growing in a known direction (tianpan.co). Concretely, put checks on these events:

  • value_complete for scalars. Type check, enum membership, range, regex, required-field presence.
  • container_close for objects. Cross-field invariants such as "if status is refunded then refund_id must be present."
  • key_start for unknown keys. If your schema is strict, an unexpected key is a failure you can detect immediately, before the value is even generated.

There is a real cost argument for early detection. If the model emits {"score": "high" and your schema says score is a number, catching it at the closing quote lets you re-prompt or retry cheaply, because the model has not yet generated the rest of the document. Catching it after the closing brace is a wasted full generation (tianpan.co).

The tools you already use fit here. Pydantic models can be validated from JSON text and can generate the JSON schema that constrains generation in the first place (Pydantic for LLMs). The pattern is to parse incrementally, then hand each completed object to the same validator you use on the final result. That way there is exactly one definition of correct, and the streaming path cannot drift from the batch path.

One habit worth building: treat every completion as untrusted input, because it is. Truncation from a token limit, a timeout or a dropped stream are all ordinary events, and the worst outcome is not a crash but a half-parsed object that flows downstream and corrupts something three steps later (discussion of truncated LLM responses). The second link is a comment thread; the point holds regardless of who says it.

Keep a last known-good snapshot

A stream that dies mid-document is not a failure of your parser. It is a normal transport event, and the question is what state you are left holding. Straightforward retry-on-error advice covers the transport layer: exponential backoff with jitter for transient errors, and extra care for streaming because you may already have sent partial content to the user (error handling and retries for LLM calls). The application layer needs its own answer.

The design that works is a commit log, not a live mutation:

  1. Maintain committed, a deep copy of the object as of the last validated boundary.
  2. Maintain working, the object the parser mutates as tokens arrive.
  3. On each validated value_complete or container_close, promote working to committed.
  4. On failure or disconnect, discard working and re-render from committed immediately.
  5. Retry the request. When the new stream arrives, either replay it into a fresh working or, if your API supports it, resume and merge by key.

Step 4 is the one people skip, and it is why a mid-stream retry corrupts state: the UI has already rendered half of a value that will never be completed, and the retry writes a different half on top. Restoring the snapshot makes the regression visible and recoverable instead of silently wrong.

A worked example makes the cost concrete. Suppose the model is generating a 40-field extraction with an average of 12 characters per value, so roughly 480 characters of payload, not counting keys and punctuation. At a streaming rate that takes 4 seconds, a disconnect at second 3 leaves you with about 75 percent of the field values committed and one field half-written. If you promoted that partial field, your retry must either overwrite it (fine if it is a streamed prose field, dangerous if it is an ID) or carry a flag through every consumer. If you did not promote it, nothing downstream ever saw it, and the retry is a clean overwrite. The snapshot costs one deep copy per validated boundary, which for a 40-field object is trivial next to the retry it saves.

Two rules keep the snapshots honest. First, snapshot only at validated boundaries, never on a timer. Second, make the committed object immutable from the renderer's point of view, so a stale reference cannot be mutated by the parser after you promoted it.

Constrained generation changes what your parser has to do

It is worth being precise about what constrained decoding buys you, because it does not remove the need for a partial parser, and expecting it to is a common misread.

Constrained decoding masks the next-token distribution so only tokens that keep the partial output valid against a grammar or schema remain, which means the final document is valid by construction (ZeroEntropy). A 2025 benchmark assembled 10,000 real-world JSON schemas and evaluated six constrained-decoding frameworks on efficiency, coverage and quality, treating JSON Schema as the standardized constraint format (structured outputs benchmark). The curated list of papers, libraries and benchmarks in this space is also a good map of the terrain (awesome list).

What none of that does is tell your UI what to draw at token 40. Validity by construction is a property of the finished document, not of an arbitrary prefix. You still need the state machine to know which strings are open. You still need buffering policy to know which fields are safe to render. What you gain is that the failure mode shifts from "the JSON is broken" to "the JSON is valid but the content is wrong," which is a validation problem rather than a parsing problem, and a much better place to spend your effort.

There is also an architectural point worth remembering when you choose an interface. Structured outputs and function calling both pass schemas under the hood but serve different purposes: structured outputs are a single-turn constraint on form, while function calling is a multi-turn flow where the model pauses for your code to execute a tool (structured outputs vs function calling). Streaming a tool call means streaming arguments that must be buffered whole before execution, so if you find yourself streaming and executing arguments field by field, the design is fighting the tool.

If you are comparing the mechanism to JSON mode, structured outputs are the stricter of the two and JSON mode guarantees validity without schema adherence (Vellum on JSON mode, structured outputs and function calling). Pair a strict schema with the incremental parser and you get both guarantees: valid shape from generation, and a usable rendering at every step.

Putting it together

A reference pipeline, in order:

  1. Request schema-constrained output if the provider supports it, so shape errors are impossible rather than repaired.
  2. Feeding chunk text into the state machine, not into a JSON parser. Track string, escape, number and container context.
  3. Emit events, not just snapshots, so the view layer can choose per-field render policy.
  4. Buffer atomic values, render streamed ones, and never let a buffered field emit a partial value.
  5. Validate at value_complete and container_close, using the same schema object that constrained generation.
  6. Promote to a committed snapshot only at validated boundaries, and keep the working copy separate.
  7. On failure, re-render from the committed snapshot, then retry with backoff.

The complexity is genuinely in steps 2 and 6, which is why the ecosystem exists rather than every team writing their own. The ZeroEntropy overview notes that building a robust constrained-decoding system for arbitrary tokenizers is non-trivial, and most production teams use established libraries rather than rolling their own; the same advice applies to the parsing side. Use a library, wrap it in the event and snapshot layers, and spend your own effort on the validation rules that are specific to your domain.

If you are routing several model families through one endpoint, keeping the parser and schema in one place gets easier. Quanta Inference serves text, image, video, audio and embedding models through a single OpenAI-compatible API key with prepaid US dollar pricing, so the streaming client and its partial parser stay the same across providers (https://quantaintelligence.ai/inference). For teams that would rather hand the whole extraction pipeline to someone who does it weekly, Quanta Experts takes your context and returns finished deliverables (https://quantaintelligence.ai/agents).

FAQ

FAQ

Why not just use a JSON repair library on every chunk?

Repair libraries are designed for the failure case: a truncated response, a dropped connection, or markdown fences wrapping the JSON. Running one on every chunk re-parses the full prefix each time, which turns linear work into quadratic work, and a published measurement of that pattern shows it degrading badly past a few kilobytes. Use the repair step once, at the end, when the stream has actually failed.

How do I know when a string value is complete versus still growing?

The closing quote ends it, and only an unescaped one counts. Your state machine must have a dedicated escape state so that \" inside a string does not terminate it, and so that \u escapes consume their four hex digits. Until the closing quote arrives, any value you hold for that field is provisional and should not be promoted to committed state.

Should numbers be rendered as soon as digits arrive?

No. Numbers are greedy: 12 may continue to 1230, and 1. may become 1.5e3. Commit a number only when a delimiter arrives, meaning any character outside the number alphabet (digits, sign, decimal point, exponent markers). This one rule removes a whole class of flickering-value bugs in dashboards.

What is the right granularity for validation?

Validate when a value is decided, which for scalars means the closing quote or the delimiter, and for containers means the closing bracket. Add a key-level check for unexpected keys at key_start, since a strict schema can reject those before any value is generated. Per-token validation is both undecidable for most rules and far more expensive.

Does constrained decoding make a partial parser unnecessary?

No. Constrained decoding guarantees the finished document is valid against the schema, but an arbitrary prefix of that document is still incomplete, and your UI still needs to know which strings are open and which containers are unbalanced. What constrained decoding removes is the repair path for malformed syntax, which means you can spend your validation budget on content instead of structure.

Sources

Sources

  1. Streaming JSON Parsers: The Gap Between Tokens and ...
  2. Constrained decoding: forcing LLM output to a grammar - ZeroEntropy
  3. best-effort-json-parser
  4. jbingen/jsonchunk: Parse partial JSON as it streams in. ...
  5. JSON River, Parse JSON incrementally as it streams in
  6. How to Use Pydantic for LLMs: Schema, Validation & Prompts
  7. Truncated LLM responses breaking JSON.parse is one ...
  8. Error Handling & Retries: Making LLM Calls Reliable
  9. Generating Structured Outputs from Language Models: Benchmark and ...
  10. GitHub - Saibo-creator/Awesome-LLM ...
  11. Structured Outputs vs. Function Calling: Which Should Your Agent ...
  12. When should I use function calling, structured outputs or JSON mode?
About the author

About the author

Antonio Evans
Antonio Evans

Antonio Evans is the founder of Quanta Intelligence and a fractional CTO who builds HIPAA-ready AI systems for health and life-science companies. Before that he was technical co-founder of FriendsEAT for twenty years, CEO of Socialdraft, and a data engineer at IBM. Antonio lives with his dog Santino in Lisbon, Portugal.

All articles