NDJSON vs partial JSON: the LLM streaming choice that decides whether you can validate at all
Everyone treats this as a parsing problem and reaches for a parser. The question that actually decides it is what the parsed object is going to be used for.

A model emitting structured output produces, for most of its runtime, something that is not valid JSON. That is not a defect. It is what token-by-token generation looks like from the outside, and it is why the first thing every team reaches for is a parser that can cope.
The choice between NDJSON vs partial JSON for LLM streaming gets framed as a parsing problem, and framed that way it has an obvious answer: use the parser. The question that actually decides it is what the parsed object is for. If it is prose a person reads as it appears, a resilient parser is the right tool. If it is structure something else will act on — a screen, a query, a transfer — then the format determines whether you are able to check the structure at all, and one of these two options quietly removes that option.
The short version
Pick partial JSON parsing if the output is text a human reads as it lands, and a value that appears and then corrects itself costs nothing.
Pick a delimited format if the output is structure that something will act on, and you need each unit checked before it has effects.
They are not interchangeable. JSON Schema keywords are defined over complete documents, so "validate the fragment as it grows" is not a stricter version of validation — it is a different and weaker check. A resilient parser and a hard validation gate cannot both be in the same pipeline.
What partial JSON parsing is
A partial JSON parser accepts a truncated document and returns the most complete value it can infer, closing whatever is still open.
The reference implementation is promplate/partial-json-parser-js, which OpenAI's Node library vendors, and it has been ported widely. On iOS there are two: itruf/PartialJSON and mattt/PartialJSONDecoder. All READMEs checked 17 August 2026.
The behaviour is documented plainly, which is to the libraries' credit:
parse('{"key": "v', STR | OBJ) → { key: "v" }
parse('{"key": "v', OBJ) → { }
parse('[1, 2, 3') → [1, 2, 3]An Allow flag set decides which types may be left incomplete — STR, NUM, ARR, OBJ, SPECIAL, or ALL, which is the default. The Swift port defaults to .allExceptNumbers. Types outside the allowlist are withheld until they are complete, which is the strictness dial the libraries offer.
Read those three lines again as a contract rather than as examples. In the first, the string "v" is a value the model has not finished writing, returned as though it had. In the second, the same field disappears entirely. Neither result is wrong — both are exactly what the flags asked for — and neither is distinguishable downstream from a value the model actually produced.
The case for this is real and worth stating at full strength. One engineering write-up from March 2026 puts it as a straight user-experience argument: without streaming the user watches a spinner for the length of the generation; with it, the first token lands in about half a second. Its recommendation is a resilient parser that "transforms broken partial JSON into typed objects in real time". For a chat surface that is the correct call, and the numbers are its own — measured on its own stack, not on yours.
What NDJSON is
NDJSON moves the framing problem out of the parser and into the transport. One complete JSON object per line, \n as the delimiter, no enclosing array. The document has no end, so there is never a closing bracket to wait for.
Nothing about it is novel; it is what everything else already uses when data arrives over time. Elasticsearch's bulk API takes it. OpenAI's and Anthropic's batch and fine-tuning uploads are .jsonl. Ollama streams NDJSON. Log pipelines emit it, because a log stream has no natural moment at which to write the last byte.
A screen arriving as NDJSON looks like this:
{"type":"screen_start","screenId":"scr_01","presentation":"summary","title":"August bill"}
{"type":"callout","screenId":"scr_01","tone":"information","body":"Your bill is £14.20 higher than July."}
{"type":"metrics","screenId":"scr_01","items":[{"label":"Data","value":"14.2 GB","tone":"negative"},{"label":"Calls","value":"212 min"}]}
{"type":"screen_commit","screenId":"scr_01"}The progressive rendering is unchanged: the heading appears, then the callout, then the metric row, in the order they arrive. What differs is that at every instant the receiver is holding a set of finished objects and at most one incomplete line it has not looked at yet. There is no state in which a half-value is visible.
The cost is real and belongs here rather than in a footnote. You give up sub-object granularity. A long narrative paragraph arrives as one record when it is finished, not word by word, so the character-by-character typing effect that chat interfaces have trained everyone to expect is not available inside a field. If that effect is the product, this is the wrong format.
What they share
They are conflated because on the surface they solve the same problem, and quite a lot of the description is genuinely common.
Both stream over the same transport. Server-sent events or a chunked HTTP body. The format is what is inside the chunks, not how the chunks travel.
Both give you early first paint. Neither makes the user wait for generation to finish. Anyone claiming a latency advantage for one over the other is describing their own implementation.
Both need a buffer. Chunk boundaries are not value boundaries in either design. One buffers to a delimiter; the other buffers and re-parses. The work is comparable.
Neither is a property of the model. Both are decisions about the wire, made downstream of whatever the provider emits. This is the part that gets lost: you are not choosing how the model generates, only what you are willing to accept from it.
Axis 1 — what you can check, and when
Schema validation is defined over complete documents. required cannot fail on a fragment, because the missing field might arrive next. oneOf cannot resolve, because the discriminating property might not be there yet. minItems, maxItems and additionalProperties: false all have the same shape of non-answer.
So a validator run against a growing fragment is not a stricter version of the same check. It is a different check that reports "not yet contradicted", and the two are easy to mistake for each other in a passing test suite.
That is the whole of the argument. A hard validation gate needs a complete unit to act on. Partial parsing exists precisely so that you do not have to wait for one. You can have either property; a pipeline containing both is one where the gate runs after the fragment has already rendered, which is not a gate.
Axis 2 — what a truncated value becomes
Take parse('{"key": "v', STR | OBJ) → { key: "v" } and put it in each of the two products.
In a chat bubble. A word appears half-formed and completes itself a frame later. Users have been trained on this since 2023 and read it as the machine thinking. Cost: nothing.
In a screen. The truncated string is a metric value, a heading, or a comparison cell. It renders in your app's own type, at its final size, in a layout that gives it the authority of retrieved data. Nothing marks it provisional, because nothing in the object records that it was completed rather than received. If the layout reflows when the real value lands, the user saw a number that was never true; if it does not, they may still be looking at it.
Completion is repair with a friendlier name. The parser is inventing the tail of a value the model did not finish emitting, and an invented value that renders is indistinguishable from one the model produced.
That connects this decision to a rule one layer up: rejecting rather than repairing invalid model output. A resilient parser is a repair step that runs before your validator ever sees the bytes, which is why the format choice has to be made first.
Axis 3 — what happens when the stream just stops
Streams stop. The connection drops, the provider rate-limits mid-generation, the user backgrounds the app, the token budget runs out.
With partial parsing, the last object you hold is complete-looking. The parser closed it, because closing things is its function, and there is no signal in the returned value that distinguishes "this is what the model sent" from "this is what was on the wire when it died". Detecting truncation means a separate mechanism outside the parse.
With a delimited format, the last line either arrived whole or it did not. The absence of the terminator is the signal, and it is available without any extra machinery. That in turn makes abort a state you can define: a stream that ends without its terminal record has not produced a screen, and the correct response is to restore what the user had.
NDJSON vs partial JSON for LLM streaming: which to pick
Pick by what the output is, not by what the streaming feels like.
- Is the output read, or acted on? Prose a person reads → partial parsing. Structure that becomes UI, a query, or an instruction → delimited.
- Does a value that changes after it appears cost anything? If a corrected word is invisible, partial parsing is free. If a corrected metric is a support ticket, it is not.
- Do you need a place to say no? A validation gate needs a complete unit. If there is no point in the pipeline where the unit is complete before it has effects, there is no gate.
- Is character-level progressive rendering the product? If the typing effect inside a field is the experience you are selling, partial parsing is the only option and the rest of this is a tradeoff you are choosing deliberately.
Most teams building a model-composed screen on iOS answer acted-on, expensive, yes, no — and the format follows. Most teams building a chat panel answer the other way, and they are also right.
How Uzori does it
The pipeline buffers incoming text and does nothing with it until it finds a \n. The line up to that newline is a candidate record; everything after it stays in the buffer.
Each candidate line then passes six checks in order — JSON Schema, screen identity, navigation match, stream grammar state, grounding, and cardinality — and only then is it forwarded, as the original bytes. There is no re-serialisation step, so what the device renders is byte-identical to what passed. A line that fails any check is rejected rather than patched; one bounded re-ask is permitted only when the first record fails and nothing has been forwarded to the client.
Two constraints make the framing enforceable rather than conventional. Blank lines are illegal, so an empty line is a protocol error rather than a no-op. And a line has a ceiling of 16,384 bytes, so a model that never emits a newline is cut off rather than buffered indefinitely.
The stream must end with a screen_commit record. A stream that stops before it has not produced a screen: the draft content was never interactive, and the client restores the screen the user had before the turn. That is the failure mode Axis 3 describes, handled as a state rather than as an exception, and it is one of the constraints in what a generated screen is structurally unable to do.
Frequently asked questions
Why does partial JSON break a streaming UI?
It does not break the rendering — it breaks the checking. A parser that closes an unterminated value returns something indistinguishable from a value the model finished, so any validation you run downstream is validating a mixture of received and invented data. In a text surface that is invisible. In a UI the invented value renders at full size with the same authority as the rest.
Can you validate JSON before it has finished streaming?
Not against a schema, because schema keywords describe complete documents. required and oneOf cannot fail on a fragment that might still grow. You can check a fragment for "nothing contradicted yet", which is useful and much weaker. Real validation needs a complete unit, which is what a delimited format gives you several times per response instead of once.
Is NDJSON better than JSON for LLM streaming?
It is better when each unit needs to be checked before it has effects, and it is unnecessary when the output is prose. NDJSON is already the default for streaming elsewhere — Ollama's responses, .jsonl batch uploads, bulk APIs — because a stream has no natural moment to write a closing bracket. LLM structured output is the outlier, mostly because the tooling assumed one large object.
Do I still need a partial JSON parser if I use structured outputs?
Structured outputs constrain what the finished document looks like, not what an interrupted one looks like. Halfway through generation you still hold invalid JSON. So yes, if you want to display something before the document ends and you have chosen one large object, you need the parser — which is the same fork this article describes, arriving one layer later.
Where to start
Answer the four questions above against your own output, in order, before you compare libraries. The format is downstream of what you decided the output is for, and picking a parser first quietly answers the question by making one of the options impossible.
If the answer is "structure, and I need a place to say no", the shipped wire schema and stream grammar are what we would show you first — one record per line, six checks, and the original bytes forwarded. Email hello@uzori.ai and ask for them.