Streaming JSON Parser Demo
Drag a slider through a JSON document byte by byte and watch which fields are safe to render and which are still being written.
Everything on this page runs in your browser. Nothing is uploaded, logged or sent anywhere — your input is kept in the address bar so a link reopens it, which also means anything you paste travels with the link. Do not share a link to a payload you would not publish.
The value still being written is $.items[1]. Everything else in the object below has been terminated by a comma or a bracket and will not change.
{
"status": "ok",
"items": [
{
"id": 1,
"name": "Widget",
"price": 12.5,
"in_stock": true
},
{
"id": 2,
"name": "Gasket",
"price": 3{
"status": "ok",
"items": [
{
"id": 1,
"name": "Widget",
"price": 12.5,
"in_stock": true
},
{
"id": 2,
"name": "Gasket",
"price": 3
}
]
}Do not render the last number. It touches the end of the buffer with nothing terminating it. A partially received 12 is indistinguishable from the start of 129 or 12.5, so a UI that shows it will show a different number a moment later — and a pipeline that acts on it acts on a number nobody sent. The same holds for t, tr and tru.
- Bytes received
- 190 of 307
- Fraction of the document
- 62%
- Complete keys recovered
- 9
- Containers still open
- 2
- Value being written
- $.items[1]
- Inside a string
- no
What is unfinished, and where
- checkNumber touches the end of the inputline 13, column 16
Nothing terminates it — no comma, no bracket. `123` at the end of a buffer may be the start of `1234` or of `123.4`, so a partial parser must not surface it. This is the failure people hit when they render a streamed number.
"price": 3 ^ - must fixObject never closedline 13, column 17
The input ends inside the object. A `}` was added.
"price": 3 ^ - must fixArray never closedline 13, column 17
The input ends inside an array. A `]` was added.
"price": 3 ^ - must fixObject never closedline 13, column 17
The input ends inside the object. A `}` was added.
"price": 3 ^
// parsePartial(buf) — parse a prefix of a JSON document.
// Returns the value received so far, plus the path of the value still being
// written. No dependencies, no exceptions; a prefix is never an error.
function parsePartial(buf) {
let i = 0;
let openPath = null;
const value = parseValue([]);
return { value, openPath, complete: openPath === null };
function mark(path) { if (openPath === null) openPath = path.slice(); }
function ws() { while (i < buf.length && ' \t\n\r'.includes(buf[i])) i++; }
function parseValue(path) {
ws();
if (i >= buf.length) { mark(path); return undefined; }
const c = buf[i];
if (c === '{') return parseObject(path);
if (c === '[') return parseArray(path);
if (c === '"') return parseString(path);
return parseAtom(path);
}
function parseString(path) {
i++;
let out = '';
while (i < buf.length) {
const c = buf[i];
if (c === '"') { i++; return out; }
if (c === '\\') {
// An escape may itself be cut in half: \ or \u12 at the buffer edge.
if (i + 1 >= buf.length) break;
const e = buf[i + 1];
if (e === 'u') {
if (i + 6 > buf.length) break;
out += String.fromCharCode(parseInt(buf.slice(i + 2, i + 6), 16));
i += 6;
continue;
}
out += { n: '\n', t: '\t', r: '\r', b: '\b', f: '\f' }[e] ?? e;
i += 2;
continue;
}
out += c;
i++;
}
mark(path); // unterminated: this string may still grow
i = buf.length;
return out;
}
function parseAtom(path) {
const start = i;
while (i < buf.length && !',}] \t\n\r'.includes(buf[i])) i++;
const raw = buf.slice(start, i);
// A token that touches the end of the buffer is not finished: `12` may
// become `123`, and `t` may become `true`. Do not surface it.
if (i >= buf.length) { mark(path); return undefined; }
if (raw === 'true') return true;
if (raw === 'false') return false;
if (raw === 'null') return null;
const n = Number(raw);
return Number.isNaN(n) ? undefined : n;
}
function parseObject(path) {
i++;
const out = {};
for (;;) {
ws();
if (i >= buf.length) { mark(path); return out; }
if (buf[i] === '}') { i++; return out; }
if (buf[i] === ',') { i++; continue; }
if (buf[i] !== '"') { mark(path); return out; }
const keyStart = i;
const key = parseString(path);
if (i >= buf.length && buf[buf.length - 1] !== '"') {
// The KEY itself is half-written; it is not a property yet.
i = keyStart;
mark(path);
return out;
}
ws();
if (buf[i] !== ':') { mark(path); return out; }
i++;
const v = parseValue(path.concat(key));
if (v !== undefined) out[key] = v;
else return out;
}
}
function parseArray(path) {
i++;
const out = [];
for (;;) {
ws();
if (i >= buf.length) { mark(path); return out; }
if (buf[i] === ']') { i++; return out; }
if (buf[i] === ',') { i++; continue; }
const v = parseValue(path.concat(out.length));
if (v === undefined) return out;
out.push(v);
}
}
}TextDecoder ({ stream: true }) before this parser ever sees the text, or you will get a replacement character in the middle of a word. This page also assumes one JSON document per stream; server-sent events wrap a separate document in every data: line, which is a different problem.Settled and in-flight
A value in a JSON prefix is in one of two states. It is settled if something after it terminated it — a comma, a closing brace, a closing bracket. It is in flight if the buffer ends inside it. Every value on the path shown above is in flight; everything else is settled and will never change no matter how many more bytes arrive. That distinction is the whole content of a partial parser, and getting it wrong is what produces the two bugs everybody ships.
The two bugs
The first is rendering an in-flight number. Streaming text is forgiving because a half-written word still reads as a word getting longer; a half-written number is a different valid number, and 1 then 19 then 199 in a price field is a UI that lies three times before it tells the truth. The second is treating an in-flight object as complete because it happens to have all the keys you were looking for — the model was not finished, and the key you checked for may still be followed by one that changes the meaning.
The parser above handles both. It refuses to return a token that touches the end of the buffer, it refuses to add a property whose key is half-written, and it reports the path of the value it stopped inside so the caller can render that one differently. A string is the exception: it returns the partial text, because a growing string is what streaming is for.
Why not just wait for the whole thing
Often you should. Partial parsing buys perceived latency and nothing else — if the object is small, or if you cannot act on any field until you have all of them, buffering to the end is simpler and has no failure modes. It is worth the complexity when one field is long and the rest are short: a summary or a message field that takes seconds to generate, sitting next to metadata that arrived immediately. Stream that one field, treat everything else as settled, and the parser above tells you which is which.