Building ToolRunner·10 min read

What Actually Breaks When You Open Large JSON in a Browser Tab

Why big JSON freezes browser tabs: JSON.parse cost vs DOM node count, how ToolRunner's JSON Viewer renders trees, and the honest limits of client-side.

by Dowon Oh

Every backend engineer has done this: an API returns a 40MB response, you save it to a file, and then you go looking for something that will let you see it. Your editor chokes, less shows you a single unreadable line, and eventually you paste it into some browser-based JSON viewer — and the tab freezes. I run one of those viewers (/json-viewer/), so I have spent a fair amount of time thinking about exactly where that freeze comes from. The intuition most people have — "parsing big JSON is slow" — turns out to be mostly wrong. Parsing is the cheap part. What kills the tab is what happens after the parse: the number of DOM nodes a naive renderer tries to create, and the fact that all of this competes for a single main thread with your keystrokes and scroll events. This article walks through the actual failure modes, how ToolRunner's viewer handles them, where my implementation honestly does not handle them, and what you should think about before pasting a production payload into any browser tool — mine included.

JSON.parse is fast, but it is not free

JSON.parse is one of the most heavily optimized code paths in any JavaScript engine. It is implemented in native code, it does a single pass over the input, and for pure data it is reliably faster than executing the equivalent object literal as JavaScript — enough so that bundlers sometimes ship large config objects as JSON.parse("...") strings on purpose. The MDN reference for JSON.parse describes the contract: one string in, one fully materialized object graph out.

That last part is the catch. JSON.parse is synchronous and monolithic. There is no streaming variant in the standard library, no way to get the first half of the object while the second half is still parsing, and no way to yield to the event loop midway. For a multi-megabyte document, the parse itself plus the allocation of every object, array, and string in the result happens in one uninterruptible block on the main thread. While that block runs, nothing else does: no input handling, no rendering, no scrolling. A parse that takes long enough becomes indistinguishable from a frozen tab, even though the browser is working exactly as designed.

In the viewer's code, this cost is very visible — and, I will admit up front, paid more than once. When the input changes, a useMemo runs JSON.parse to decide whether the document is valid (which controls whether the Repair button lights up), and a second useMemo parses it again to build the content object for the preview pane:

const isValidJson = useMemo(() => {
  if (!input) return true;
  try { JSON.parse(input); return true; } catch { return false; }
}, [input]);

const previewContent = useMemo((): Content => {
  if (!input) return { text: '' };
  try { return { json: JSON.parse(input) }; } catch { return { text: input }; }
}, [input]);

For a 100KB payload this is irrelevant. For a 15MB payload, it means every edit re-parses the full document on the main thread, twice. There is a 20MB hard limit on file uploads — not because 21MB is impossible to parse, but because somewhere past that point the synchronous parse-per-edit model stops being an acceptable user experience, and I would rather refuse a file than freeze your tab and let you draw your own conclusions.

The DOM is where large JSON actually dies

Here is the part that surprises people: even when the parse completes in a few hundred milliseconds, rendering the result can be an order of magnitude worse. A JSON document with a million values is, to JSON.parse, a million cheap allocations. To a naive tree renderer, it is a million expandable rows — and each row is not one DOM node but several: an element for the row, one for the expand caret, one for the key, one for the value, plus the event listeners and style resolution that come with each.

DOM nodes are not just memory. Every node participates in style calculation, and enough of them participate in layout that large subtree insertions trigger expensive reflow work. The browser's rendering pipeline — style, layout, paint, composite — scales with the amount of content it has to consider, and as web.dev's rendering-performance guidance lays out, all of that work shares the same main thread as your JavaScript. Inserting hundreds of thousands of nodes in one commit does not just take long once; it leaves behind a document that is permanently more expensive to style, hit-test, and scroll for as long as those nodes exist. This is why "the tab froze while opening the file" and "the tab is sluggish forever after" are two different symptoms with the same root cause.

The practical consequence: for large documents, the dangerous button is not "Open." It is "Expand all."

How the viewer actually renders, and what I did not build

I did not write the tree renderer myself, and that was a deliberate decision. The viewer embeds two instances of vanilla-jsoneditor — a plain-text editing pane on the left and a read-only preview on the right that can switch between tree, text, and table modes. Both instances are lazy-loaded via dynamic import() behind React.Suspense, so visitors who came for the format-and-copy workflow never download the editor bundle cost up front for features they are not using yet.

The tree mode's survival strategy against the DOM explosion described above is fundamentally about not rendering what you have not asked for: nested structures start collapsed, and cost is incurred as you drill in, roughly proportional to what is actually on screen rather than to the size of the document. That is the right default — it converts a catastrophic upfront cost into an incremental one that tracks user intent.

Now the honest accounting, because this is where the article earns its title:

  • There is no Web Worker. Every JSON.parse, every JSON.stringify behind the Format and Minify buttons, and every jsonrepair pass behind the Repair button runs on the main thread. Moving the parse to a worker would keep the UI responsive during it, at the cost of structured-clone overhead to get the result back — for a viewer whose whole job is to hand the parsed object to a renderer on the main thread anyway, that transfer cost is real, which is part of why I have not done it. But the trade-off is genuine: past a few megabytes, you will feel the parse as input latency.
  • There is no virtualization layer in my code. I rely entirely on the embedded editor's collapse-by-default behavior. If you expand a node that directly contains an enormous amount of visible content, the DOM cost is paid in full. Windowed rendering — only materializing rows in and near the viewport — is the textbook fix, and I have not built it.
  • Re-parsing is per-edit, not incremental. The memoization avoids re-parsing on unrelated re-renders, but any change to the input text invalidates everything. There is no incremental parser tracking which subtree changed.

These are not oversights I am pretending are features. They are the actual trade-off frontier of a tool that is deliberately dependency-light and runs entirely client-side: every mitigation (workers, virtualization, incremental parsing) buys headroom for the 50MB case by adding permanent complexity to the 50KB case that represents almost all real usage.

Querying instead of scrolling: JMESPath

For large payloads, the best rendering strategy is to not render most of the document at all. This is why the viewer's Advanced panel includes a JMESPath query tab. JMESPath is a specified query language for JSON — the same one behind aws cli --query — with a real grammar and a compliance test suite, which is exactly what you want compared to each tool inventing its own dot-path dialect.

Against a payload like this:

{
  "contributors": [
    { "name": "Alice", "role": "developer", "active": true },
    { "name": "Bob", "role": "designer", "active": false },
    { "name": "Charlie", "role": "tester", "active": true }
  ]
}

the query

contributors[?active].name

returns ["Alice", "Charlie"] — a two-element result rendered into a small scrollable block, instead of a tree you have to expand and visually scan. For a response with ten thousand records, filtering down to the three you care about sidesteps the entire DOM problem: the expensive document stays as an in-memory object, and only the answer becomes nodes.

The implementation is small enough to describe completely. The jmespath package is loaded lazily on first query, so it costs nothing until used. Input is debounced at 300ms, so the query runs when you pause typing rather than on every keystroke. And — honest limit again — executeQuery in src/lib/jmespathQuery.ts takes the raw JSON text and calls JSON.parse on it for every query execution, rather than reusing the already-parsed preview object. On a large document you pay a full re-parse per query, on the main thread, with the synchronous jmespath.search on top. The result is then JSON.stringify-ed with indentation into a single block, which means a query that selects most of a huge document produces a huge string; the mitigation is behavioral, not architectural — write narrower queries, which is what JMESPath is for.

Before you paste a production payload anywhere

This section is the one I would want to read as a backend engineer, so here it is without marketing gloss.

Everything runs client-side, and that is verifiable. The viewer sends nothing to a server — there is no server to send it to. Parse, format, repair, schema validation, and JMESPath all execute in your tab. You can confirm this yourself from the network panel, which is a stronger guarantee than any privacy policy.

But "client-side" does not mean "leaves no trace." The viewer syncs your input into the URL query string (lz-string compressed) so you can share and bookmark tool state. Two properties matter here. First, there is a hard cap: if the resulting URL would exceed 8,000 characters, the sync is skipped entirely — so large payloads never land in the URL at all. Second, for small payloads that do fit, the URL now contains your data: the sync uses history.replaceState, so it does not pile up a history entry per keystroke, but the visited URL — query string included — can still be recorded in your browser history, and if you copy the address bar into a chat, you have shared the payload. The hook deliberately strips its parameters when you navigate away, but anything recorded or copied while you worked is yours to manage. If the payload contains bearer tokens, session cookies, or customer PII, treat any URL from any tool as a sharing surface — the same class of thinking I apply to response headers in the HTTP Security Headers guide.

Sanitize before you inspect. If you are debugging a production response, the structure is usually what you care about, not the values. A quick jq 'walk(if type == "string" then "x" else . end)' before pasting gives you the shape without the secrets. This is the same discipline as not pasting production Authorization headers into a shared terminal, and it applies to my tool exactly as much as anyone else's.

Match the tool to the size. Up to a few megabytes, the viewer's tree mode is comfortable. In the tens of megabytes, prefer the text preview over the tree, lean on JMESPath instead of expanding nodes, and know that the 20MB upload cap exists because beyond it I can no longer keep the synchronous model honest. Past that, you are in jq territory on your own machine, and that is the correct answer, not a failure of tooling. For adjacent jobs, the same client-side model backs the JSON to YAML converter and the Diff Checker — the latter being the better tool when your actual question is "what changed between these two payloads" rather than "what is in this one."

The browser is a genuinely good place to inspect JSON — the parse is fast, the sandbox is real, and nothing has to leave your machine. It just is not magic: one thread, one synchronous parser, and a rendering pipeline that charges by the node. A viewer that is honest about those constraints, and gives you tools like collapsed-by-default trees and JMESPath to work with them, will take you a long way. Knowing where its limits are is what lets you trust it the rest of the way.

Sources

Specifications

Mozilla MDN

web.dev

Further reading