{"componentChunkName":"component---src-templates-blog-post-js","path":"/blog/2026-09-05-parse-partial-json-llm-streaming/","result":{"data":{"site":{"siteMetadata":{"title":"M.Hassan Ahmed","author":"Hassan11196"}},"markdownRemark":{"id":"c99c50ef-9ee9-50ae-b0fd-acff1649e1b8","excerpt":"You asked the model for a JSON object, and you stream the response so the UI feels alive. The problem shows up on the first render. Until the final token lands…","html":"<p>You asked the model for a JSON object, and you stream the response so the UI feels alive. The problem shows up on the first render. Until the final token lands, your buffer does not hold valid JSON; it holds a prefix. <code class=\"language-text\">JSON.parse</code> on a prefix throws, so the naive approach (parse on every chunk and render the result) fails on every chunk except the last. At that point you might as well have waited for the whole response and skipped streaming entirely.</p>\n<p>This post is for engineers streaming structured output from an LLM (OpenAI, Claude, Gemini, whatever) who want to render it progressively instead of all at once. I’ll cover why the buffer is invalid mid-stream, a completion routine you can read and adapt, the edge cases that make a naive version wrong, and when to stop hand-rolling and reach for a library.</p>\n<p>I hit this building the live document preview in <a href=\"/project/cloud-canvas-ai/\">CloudCanvasAI</a>, where the model streams a structured plan and the right-hand panel is supposed to fill in as it writes. If you render only when the object is complete, the preview sits blank for eight seconds and then snaps into existence, which is worse than a spinner. The fix is to make the incomplete buffer parseable at every step:</p>\n<ol>\n<li>Close whatever is open.</li>\n<li>Throw away whatever is half-typed.</li>\n<li>Parse the repaired string.</li>\n<li>Render the fields that have actually settled.</li>\n</ol>\n<h2>Why the buffer is invalid until the end</h2>\n<p><a href=\"https://www.rfc-editor.org/rfc/rfc8259\">JSON</a> is a closed grammar: every <code class=\"language-text\">{</code> needs its <code class=\"language-text\">}</code>, every <code class=\"language-text\">[</code> its <code class=\"language-text\">]</code>, and every string its closing quote. A parser reads the whole input and rejects anything that does not balance. That is exactly what you want from a validator, and exactly what fights you during streaming, because a prefix of a valid document is almost never valid itself.</p>\n<p>Watch a single object arrive in three chunks:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">chunk 1:  {&quot;title&quot;:&quot;Q3\nchunk 2:   budget&quot;,&quot;items&quot;:[\nchunk 3:  {&quot;name&quot;:&quot;Compu</code></pre></div>\n<p>After chunk one, the buffer is <code class=\"language-text\">{&quot;title&quot;:&quot;Q3</code>: an object with no closing brace and a string with no closing quote. After chunk three it is <code class=\"language-text\">{&quot;title&quot;:&quot;Q3 budget&quot;,&quot;items&quot;:[{&quot;name&quot;:&quot;Compu</code>: two open containers, an open string, and a value cut off mid-word. <a href=\"https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/parse\"><code class=\"language-text\">JSON.parse</code></a> throws a <code class=\"language-text\">SyntaxError</code> on all three buffers. None of them is a legal document, even though each is a legal <em>prefix</em> of one.</p>\n<p>The insight that makes this tractable: a prefix is one repair away from valid. If you know which structures are open and whether you are inside a string, you can append the missing closers and parse the result. The pipeline below is the whole idea.</p>\n<p><img src=\"/f3b4c081f9255aebed1dcb5a4ed9bf08/partial-json-pipeline.svg\" alt=\"A four-stage pipeline that turns a streaming LLM response into renderable structured data. Tokens arrive one chunk at a time and are appended to a text buffer, which at any instant holds invalid JSON such as an open string and two unclosed brackets. A completion step walks the buffer, tracks open structures on a stack, discards a half-typed trailing token, and appends the closers needed to make it valid. JSON.parse then produces a partial object, and the UI renders only the settled fields while leaving the field still being written blank. A lower panel lists the open-structure stack and the edge cases a naive version gets wrong.\"></p>\n<h2>Completing the buffer</h2>\n<p>The core routine walks the buffer once and tracks two things: a stack of open containers, and whether the cursor is currently inside a string. Braces and brackets count only when you are <em>not</em> inside a string; otherwise a <code class=\"language-text\">}</code> in someone’s <code class=\"language-text\">&quot;name&quot;</code> value would throw off the balance. At the end, the function closes the open string, if there is one, then appends the missing closers, innermost first:</p>\n<div class=\"gatsby-highlight\" data-language=\"js\"><pre class=\"language-js\"><code class=\"language-js\"><span class=\"token comment\">// Best-effort completion of a JSON prefix so JSON.parse can read it.</span>\n<span class=\"token comment\">// Handles the reliable part: open strings and unclosed brackets.</span>\n<span class=\"token keyword\">function</span> <span class=\"token function\">closeOpenStructures</span><span class=\"token punctuation\">(</span><span class=\"token parameter\">buffer</span><span class=\"token punctuation\">)</span> <span class=\"token punctuation\">{</span>\n  <span class=\"token keyword\">const</span> closers <span class=\"token operator\">=</span> <span class=\"token punctuation\">[</span><span class=\"token punctuation\">]</span><span class=\"token punctuation\">;</span> <span class=\"token comment\">// stack of expected closers: '}' or ']'</span>\n  <span class=\"token keyword\">let</span> inString <span class=\"token operator\">=</span> <span class=\"token boolean\">false</span><span class=\"token punctuation\">;</span>\n  <span class=\"token keyword\">let</span> escaped <span class=\"token operator\">=</span> <span class=\"token boolean\">false</span><span class=\"token punctuation\">;</span>\n\n  <span class=\"token keyword\">for</span> <span class=\"token punctuation\">(</span><span class=\"token keyword\">const</span> c <span class=\"token keyword\">of</span> buffer<span class=\"token punctuation\">)</span> <span class=\"token punctuation\">{</span>\n    <span class=\"token keyword\">if</span> <span class=\"token punctuation\">(</span>inString<span class=\"token punctuation\">)</span> <span class=\"token punctuation\">{</span>\n      <span class=\"token keyword\">if</span> <span class=\"token punctuation\">(</span>escaped<span class=\"token punctuation\">)</span> escaped <span class=\"token operator\">=</span> <span class=\"token boolean\">false</span><span class=\"token punctuation\">;</span>\n      <span class=\"token keyword\">else</span> <span class=\"token keyword\">if</span> <span class=\"token punctuation\">(</span>c <span class=\"token operator\">===</span> <span class=\"token string\">'\\\\'</span><span class=\"token punctuation\">)</span> escaped <span class=\"token operator\">=</span> <span class=\"token boolean\">true</span><span class=\"token punctuation\">;</span>\n      <span class=\"token keyword\">else</span> <span class=\"token keyword\">if</span> <span class=\"token punctuation\">(</span>c <span class=\"token operator\">===</span> <span class=\"token string\">'\"'</span><span class=\"token punctuation\">)</span> inString <span class=\"token operator\">=</span> <span class=\"token boolean\">false</span><span class=\"token punctuation\">;</span>\n      <span class=\"token keyword\">continue</span><span class=\"token punctuation\">;</span>\n    <span class=\"token punctuation\">}</span>\n    <span class=\"token keyword\">if</span> <span class=\"token punctuation\">(</span>c <span class=\"token operator\">===</span> <span class=\"token string\">'\"'</span><span class=\"token punctuation\">)</span> inString <span class=\"token operator\">=</span> <span class=\"token boolean\">true</span><span class=\"token punctuation\">;</span>\n    <span class=\"token keyword\">else</span> <span class=\"token keyword\">if</span> <span class=\"token punctuation\">(</span>c <span class=\"token operator\">===</span> <span class=\"token string\">'{'</span><span class=\"token punctuation\">)</span> closers<span class=\"token punctuation\">.</span><span class=\"token function\">push</span><span class=\"token punctuation\">(</span><span class=\"token string\">'}'</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n    <span class=\"token keyword\">else</span> <span class=\"token keyword\">if</span> <span class=\"token punctuation\">(</span>c <span class=\"token operator\">===</span> <span class=\"token string\">'['</span><span class=\"token punctuation\">)</span> closers<span class=\"token punctuation\">.</span><span class=\"token function\">push</span><span class=\"token punctuation\">(</span><span class=\"token string\">']'</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n    <span class=\"token keyword\">else</span> <span class=\"token keyword\">if</span> <span class=\"token punctuation\">(</span>c <span class=\"token operator\">===</span> <span class=\"token string\">'}'</span> <span class=\"token operator\">||</span> c <span class=\"token operator\">===</span> <span class=\"token string\">']'</span><span class=\"token punctuation\">)</span> closers<span class=\"token punctuation\">.</span><span class=\"token function\">pop</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n  <span class=\"token punctuation\">}</span>\n\n  <span class=\"token keyword\">let</span> out <span class=\"token operator\">=</span> buffer<span class=\"token punctuation\">;</span>\n  <span class=\"token keyword\">if</span> <span class=\"token punctuation\">(</span>escaped<span class=\"token punctuation\">)</span> out <span class=\"token operator\">=</span> out<span class=\"token punctuation\">.</span><span class=\"token function\">slice</span><span class=\"token punctuation\">(</span><span class=\"token number\">0</span><span class=\"token punctuation\">,</span> <span class=\"token operator\">-</span><span class=\"token number\">1</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span> <span class=\"token comment\">// lone trailing backslash: escape unfinished</span>\n  <span class=\"token keyword\">if</span> <span class=\"token punctuation\">(</span>inString<span class=\"token punctuation\">)</span> out <span class=\"token operator\">+=</span> <span class=\"token string\">'\"'</span><span class=\"token punctuation\">;</span>            <span class=\"token comment\">// close the open string</span>\n  <span class=\"token keyword\">while</span> <span class=\"token punctuation\">(</span>closers<span class=\"token punctuation\">.</span>length<span class=\"token punctuation\">)</span> out <span class=\"token operator\">+=</span> closers<span class=\"token punctuation\">.</span><span class=\"token function\">pop</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span> <span class=\"token comment\">// close containers, innermost first</span>\n  <span class=\"token keyword\">return</span> out<span class=\"token punctuation\">;</span>\n<span class=\"token punctuation\">}</span></code></pre></div>\n<p>Run it on <code class=\"language-text\">{&quot;title&quot;:&quot;Q3 budget&quot;,&quot;items&quot;:[{&quot;name&quot;:&quot;Compu</code> and you get <code class=\"language-text\">{&quot;title&quot;:&quot;Q3 budget&quot;,&quot;items&quot;:[{&quot;name&quot;:&quot;Compu&quot;}]}</code>, which parses into <code class=\"language-text\">{ title: &quot;Q3 budget&quot;, items: [{ name: &quot;Compu&quot; }] }</code>. The last item’s name is truncated, but the shape is intact and the earlier fields are correct. That is enough to render the title and the list scaffold now, and let the name fill in on the next chunk.</p>\n<p>The escaped-quote handling is the part people skip and then spend an afternoon on. If the string content is <code class=\"language-text\">path is C:\\\\</code> and the stream cuts after the backslash, appending a <code class=\"language-text\">&quot;</code> produces <code class=\"language-text\">C:\\\\&quot;</code>. The closing quote is now escaped, so the string never terminates. Dropping a dangling backslash before closing avoids it, which is what the <code class=\"language-text\">escaped</code> check at the end does. The same care applies to a <code class=\"language-text\">&quot;</code> inside string content: while <code class=\"language-text\">inString</code> is true, a quote <em>closes</em> the string and every brace between quotes is ignored. That is why the string check comes first in the loop.</p>\n<h2>Edge cases that still break the parse</h2>\n<p>Closing strings and brackets covers most frames, but a few trailing states still produce a string that won’t parse. Know these before you ship, because each one shows up as an intermittent parse error that reproduces only on specific content.</p>\n<p><strong>A number mid-type.</strong> This is the common one. A buffer ending in <code class=\"language-text\">&quot;amount&quot;: 1.</code> or <code class=\"language-text\">&quot;amount&quot;: 4e</code> closes into something like <code class=\"language-text\">{&quot;amount&quot;: 1.}</code>, and <code class=\"language-text\">-</code>, <code class=\"language-text\">1.</code>, <code class=\"language-text\">4e</code>, and <code class=\"language-text\">1e-</code> are all incomplete numeric literals that JSON’s grammar rejects. Detect a trailing partial number and drop the whole <code class=\"language-text\">&quot;amount&quot;: 1.</code> pair, along with the comma before it, so the object closes cleanly without that key.</p>\n<p><strong>A partial keyword.</strong> <code class=\"language-text\">true</code>, <code class=\"language-text\">false</code>, and <code class=\"language-text\">null</code> arrive character by character, so a buffer ending in <code class=\"language-text\">tru</code> or <code class=\"language-text\">nul</code> closes into <code class=\"language-text\">{&quot;active&quot;: tru}</code>, which throws. Drop the partial literal and its key.</p>\n<p><strong>A dangling key or comma.</strong> <code class=\"language-text\">{&quot;title&quot;:&quot;Q3&quot;,</code> closes into <code class=\"language-text\">{&quot;title&quot;:&quot;Q3&quot;,}</code>, and the trailing comma is invalid JSON even though humans read it fine. Worse is <code class=\"language-text\">{&quot;title&quot;:&quot;Q3&quot;,&quot;items&quot;:</code>, a key with a colon and no value yet. Both cases mean trimming back to the last complete key/value pair before appending closers.</p>\n<p>Handling all of this correctly means the completer stops being a 20-line function. You end up trimming a trailing token, re-checking, and trimming again, which is a small parser in its own right. That is the point where I stop hand-rolling.</p>\n<h2>When to reach for a library</h2>\n<p>The routine above is worth understanding because it tells you <em>why</em> a frame failed to parse, which you will need when debugging. For production, I lean on a dedicated partial-JSON parser rather than maintaining the edge cases myself:</p>\n<ul>\n<li>On the JS side, <a href=\"https://www.npmjs.com/package/partial-json\"><code class=\"language-text\">partial-json</code></a> parses an incomplete document directly and lets you choose, per type, whether a half-formed value is emitted or dropped.</li>\n<li>If you are already on the <a href=\"https://ai-sdk.dev/docs/ai-sdk-core/generating-structured-data\">Vercel AI SDK</a>, <code class=\"language-text\">streamObject</code> and its <code class=\"language-text\">parsePartialJson</code> helper do this internally and hand you a growing partial object on each chunk. That is the cleanest option when it fits your stack.</li>\n</ul>\n<p>There is also an upstream fix worth knowing. <a href=\"https://platform.openai.com/docs/guides/structured-outputs\">OpenAI’s structured outputs</a> constrain generation to a schema, so the model can’t emit a key that isn’t in your shape. That removes a class of <em>structural</em> surprises, but it does not remove the streaming problem. A schema-constrained response still arrives as a token prefix that is invalid until the closing brace, so you still have to complete the buffer to render it early. Schema constraints and partial parsing solve different halves.</p>\n<h2>Wiring it into a React render</h2>\n<p>With a completer in hand, the UI side is small. Accumulate chunks in a ref, run the completer plus <code class=\"language-text\">JSON.parse</code> on each one, and drop the frame if parsing still fails, since the next chunk usually fixes it:</p>\n<div class=\"gatsby-highlight\" data-language=\"jsx\"><pre class=\"language-jsx\"><code class=\"language-jsx\"><span class=\"token keyword\">function</span> <span class=\"token function\">useStreamedObject</span><span class=\"token punctuation\">(</span><span class=\"token parameter\">stream</span><span class=\"token punctuation\">)</span> <span class=\"token punctuation\">{</span>\n  <span class=\"token keyword\">const</span> <span class=\"token punctuation\">[</span>obj<span class=\"token punctuation\">,</span> setObj<span class=\"token punctuation\">]</span> <span class=\"token operator\">=</span> <span class=\"token function\">useState</span><span class=\"token punctuation\">(</span><span class=\"token keyword\">null</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n  <span class=\"token keyword\">const</span> buffer <span class=\"token operator\">=</span> <span class=\"token function\">useRef</span><span class=\"token punctuation\">(</span><span class=\"token string\">''</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n\n  <span class=\"token function\">useEffect</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span> <span class=\"token operator\">=></span> <span class=\"token punctuation\">{</span>\n    <span class=\"token punctuation\">(</span><span class=\"token keyword\">async</span> <span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span> <span class=\"token operator\">=></span> <span class=\"token punctuation\">{</span>\n      <span class=\"token keyword\">for</span> <span class=\"token keyword\">await</span> <span class=\"token punctuation\">(</span><span class=\"token keyword\">const</span> chunk <span class=\"token keyword\">of</span> stream<span class=\"token punctuation\">)</span> <span class=\"token punctuation\">{</span>\n        buffer<span class=\"token punctuation\">.</span>current <span class=\"token operator\">+=</span> chunk<span class=\"token punctuation\">;</span>\n        <span class=\"token keyword\">try</span> <span class=\"token punctuation\">{</span>\n          <span class=\"token function\">setObj</span><span class=\"token punctuation\">(</span><span class=\"token constant\">JSON</span><span class=\"token punctuation\">.</span><span class=\"token function\">parse</span><span class=\"token punctuation\">(</span><span class=\"token function\">closeOpenStructures</span><span class=\"token punctuation\">(</span>buffer<span class=\"token punctuation\">.</span>current<span class=\"token punctuation\">)</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n        <span class=\"token punctuation\">}</span> <span class=\"token keyword\">catch</span> <span class=\"token punctuation\">{</span>\n          <span class=\"token comment\">// partial frame we can't repair yet; wait for more tokens</span>\n        <span class=\"token punctuation\">}</span>\n      <span class=\"token punctuation\">}</span>\n    <span class=\"token punctuation\">}</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n  <span class=\"token punctuation\">}</span><span class=\"token punctuation\">,</span> <span class=\"token punctuation\">[</span>stream<span class=\"token punctuation\">]</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n\n  <span class=\"token keyword\">return</span> obj<span class=\"token punctuation\">;</span>\n<span class=\"token punctuation\">}</span></code></pre></div>\n<p>The empty <code class=\"language-text\">catch</code> is deliberate: a frame that still can’t be repaired is skipped, and the last good object stays on screen. Two more things make this feel right instead of janky:</p>\n<ul>\n<li><strong>Render only <em>settled</em> fields.</strong> A value that is still growing (the last array item, a string mid-word) should read as “loading”, not flash truncated text that rewrites itself every 50ms. I gate on whether a field is the last one being written, and hold its final render until it stops changing.</li>\n<li><strong>Throttle the state updates.</strong> Parsing on every token is fine, but calling <code class=\"language-text\">setState</code> 200 times a second isn’t. Batching to one update per frame, or every ~60ms, keeps React from thrashing.</li>\n</ul>\n<p>This is the same discipline I wrote about in <a href=\"/blog/2026-07-13-streaming-markdown-react-llm/\">streaming Markdown into a React UI</a>, where the trap is also re-rendering faster than a human can read.</p>\n<h2>What I’d do differently</h2>\n<p>If I were starting fresh today, I’d reach for the library first. I’d drop to the hand-rolled completer only to debug a specific frame that wouldn’t parse. Writing it yourself teaches you the failure modes, but the number and keyword edge cases are exactly what a well-tested package already handles. Getting them subtly wrong means an intermittent bug that fires only on decimal amounts or boolean flags.</p>\n<p>The other lesson: decide early whether you need JSON on the wire at all. A lot of “stream structured output” problems are really “stream a list of items”. A newline-delimited format (<a href=\"https://jsonlines.org/\">JSON Lines</a>, one complete JSON object per line) sidesteps the whole completion dance. Each line is a complete object the moment its newline arrives, so you parse and render per line with no repair step.</p>\n<p>I reach for partial-JSON completion when the shape is a single nested object that has to render as a tree, like the document plans in CloudCanvasAI or the token-aware context bundles in <a href=\"/project/llm-dev-mate/\">LLM DevMate</a>. For a flat stream of results, JSON Lines is less code and has fewer edge cases.</p>\n<p>Streaming looks like a frontend nicety and turns out to be a parsing problem. Once you treat the buffer as a prefix to complete rather than a document to validate, the rest falls into place. The preview fills in as the model thinks, which is the whole reason you streamed it.</p>\n<hr>\n<p><em>Diagram by M. Hassan Ahmed, released under CC0. No external image was used for this post; the figure is original work by the author.</em></p>","frontmatter":{"title":"Parsing Partial JSON While an LLM Streams","date":"2026-09-05T00:00:00.000Z","description":"LLM structured output isn't valid JSON until the last token. Here's how to complete and parse a streaming buffer so you can render fields as they arrive.","thumbnail":{"childImageSharp":{"fluid":{"base64":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAALCAIAAADwazoUAAAACXBIWXMAAAsSAAALEgHS3X78AAABz0lEQVQozzWQW4+bMBSEeeoWG2zAYGNj7pcEQwikmzSbVH2s1O1W+97//0N6IIr0aTT28RxGWH769uRaHX93p7/N/Gf/+pmPv8C0y0e3fDxMM7/vvsH0nSaXR8RCNME0QUTRsGjNeVx+9tPNHO9Nf94frsPyYzjeh/lu5ls3XOC4n26EFYgqSFnYz7CfgzpBAQZ5KfJy5BcOaxzW4qDBbCOoYGpTjf3UZcWWyizspU80ptoJKoi5rHb8dZfz2AuwmsQTAA/WslsEausnCQ5K4OGRt+kGbCFiXJdGe6pOTtghAvfaer5QyIP1DSbJWsFLbQI3YDT8DhIf1hF8DTTsqf5ubykLZiuuhHpxcYqzUWRD3i678SqzgWsjUhOlrzKbmOzT6lj1t+78j0lju8KyicRUuVQRqiJeRaIOeSlkw+LG46UXV4hp21eYaRQkmCU8N6I6BEn3lcTWF0cophddznU/Fe1UdkZXbSRDKCZTr8/TxcjDTs99ZKp4bMO+xLlwa+XIFMJcs+Re7W6teWvNvRuOWb3nSrHMzQqxNPps8rcRVC4dqL6YeGn9PsMqtV4c7hLBfcU9ueLLyF8VO/yFCiwSmyskEvTQDRxrUJvG/wHFjUdA3AcjkAAAAABJRU5ErkJggg==","aspectRatio":1.899441340782123,"src":"/static/6075141fc24c832e20f8bc8a74a39ebe/40a76/hero.png","srcSet":"/static/6075141fc24c832e20f8bc8a74a39ebe/c972b/hero.png 340w,\n/static/6075141fc24c832e20f8bc8a74a39ebe/27625/hero.png 680w,\n/static/6075141fc24c832e20f8bc8a74a39ebe/40a76/hero.png 1360w,\n/static/6075141fc24c832e20f8bc8a74a39ebe/ed396/hero.png 2000w","sizes":"(max-width: 1360px) 100vw, 1360px"}}}}}},"pageContext":{"slug":"/2026-09-05-parse-partial-json-llm-streaming/","previous":"blog/2026-09-06-kubernetes-poddisruptionbudget-node-drains/","next":"blog/2026-09-08-ivf-index-vector-search/"}},"staticQueryHashes":["32046230"]}