Why JSON Viewers Stall on Rendering, Not Parsing

There’s a clean way to find the ceiling of any in-browser JSON viewer: open the file, then count the seconds. Most tools stop being usable somewhere between 30 MB and 100 MB. The browser tab doesn’t crash; it just stops responding. Scroll lags by half a second per row. Hover effects take a visible beat. The fan spins up and stays on.

When that happens, almost everyone’s first guess is that JSON.parse is the problem. It usually isn’t. Parse is fast — it’s a single tight C++ loop inside V8 or JavaScriptCore. What stalls these viewers is what they do after parsing: rendering millions of DOM nodes, one per key, value, brace, and indent guide. The browser’s layout engine is the bottleneck, not the parser.

Big JSON Viewer doesn’t fix the parse ceiling — we can’t. That ceiling is enforced by the browser’s memory budget and string-size limits, and it lands somewhere between 200 MB and 700 MB depending on the browser and the shape of your JSON. What we fix is the render bottleneck. The result is that once a file can be parsed, our viewer renders and scrolls it the same way it renders a 1 MB file. This article is about how.

Where the actual ceiling sits

Let’s be specific about what the browser will and won’t do, since it shapes everything downstream.JSON.parse in Chrome (V8) can handle strings up to V8’s maximum string length, which on 64-bit machines is around 500 MB of UTF-16 code units. Beyond that you can’t even load the file as a string. Safari and Firefox are lower — typically 200-300 MB before allocation failures start. Memory pressure on the tab adds more headroom or removes it depending on what else is open.

So when someone asks “how big a JSON can your viewer open,” the honest answer is “whatever your browser will parse, plus a bit.” A 200 MB JSON in Chrome is reliable. A 500 MB JSON in Chrome is usually about as far as it goes, regardless of which viewer you use. Anyone claiming much beyond that is selling you something.

Why naïve viewers stall well below the parse ceiling

Here’s what most viewers do after a successful parse: walk the parsed object recursively, emit one DOM element per key, one per value, one per opening brace, one per closing brace, plus indent guides as nested elements. For a JSON with 1 million leaf nodes (perfectly possible at 100 MB), that’s 3-5 million DOM nodes in a single document.

Browsers handle millions of DOM siblings badly. Every layout pass walks the whole tree. Every paint reconsiders all of them for visibility. Every event listener attachment costs bookkeeping. By the time you finish creating the tree, the tab is already unresponsive; by the time you scroll, every frame is fighting with the reconciler.

You don’t need to render 5 million DOM nodes to show a JSON file. You need to render the ones the user can see — about 40 rows in a typical viewport. The rest just need to exist as data, not as DOM. This is virtualization, and it’s the entire win.

Step 1: flatten the tree once, into an array

The natural shape after JSON.parse is a nested object. The natural shape for virtualization is a flat array indexed by row number. We bridge that with a one-time walk that turns the parsed object into a single JValue[] array in depth-first order. Each entry carries its depth, child range, parent pointer, raw value, and a stable numeric id.

Flat is the right shape for everything downstream. Virtualization slices by index. Search iterates linearly. Collapse/expand becomes a depth filter, not a tree mutation. The walk cost is paid once on parse; every subsequent operation is array access.

Step 2: per-render visibility filtering, with closers

On every render, we compute the visible row set with a single pass through the flat array. The function is flattenVisible(nodes, collapsed) in packages/shared/src/flattenVisible.ts. It skips entries whose ancestor is in the collapsed set, and emits a FlatRow for each survivor.

It also emits separator rows for closing {} and [], generated on the fly with a stack. The parsed object has no concept of “close brace as a row,” but the renderer needs one — otherwise nested structures don’t close visibly. Empty containers like "key": {} are a separate case: they emit as a single inline row, not as opener + closer on two lines.

Step 3: render the visible window with two layers

A scroll container with a spacer div sized to rows.length * ROW_HEIGHT drives the scrollbar. The visible rectangle gives us a window: startRow = scrollTop / ROW_HEIGHT, endRow = startRow + viewportRows + 1. We render that slice — about 40 rows on a typical screen — and nothing else.

We render the slice into two layers at the same coordinates:

  • A sticky <canvas> sized to the viewport (not to totalHeight; canvas has a 32 K pixel limit and a 100 MB JSON would blow through it). The canvas paints the visible rows on every scroll frame.
  • A transparent <div> layer with one plain <div> per visible row, holding the row’s text as a real DOM text node set to color: transparent. Users can triple-click to select a row, drag-select across rows, Cmd-C to copy. The canvas does the visuals; the invisible DOM layer does selection and accessibility.

The DOM layer is virtualized the same way as the canvas: excess <div>s are removed, missing ones are created, all in an imperative updateTextLayer pass that doesn’t go through React. React owns the parent <div ref>; the children are ours. That keeps React out of the per-scroll hot path.

Step 4: refs over state for everything in the scroll path

The biggest perf trap in interactive scroll UIs is routing high-frequency state through useState. Every state change schedules a render; renders that touch the scroll path are deadly at 60 fps.

We use refs for hoverRowIndex, hoveredTruncNodeId, hoveredFoldRowIdx, and the scroll position itself. A mouse move updates a ref and calls scheduleRender(), which queues a requestAnimationFrame that reads the refs and paints. No React rerender. The only state we keep is hoverRowForOverlay — the row that determines where the View/Copy/More button chip floats — because that affordance has to live in the React tree.

Step 5: progress that matches reality

Two flows in the app deal with byte volumes the user cares about: opening a file and downloading a shared JSON. Both use explicit byte counters wired into the streaming layer, not derived percentages.

The shared-JSON download pipes the response body through a TransformStream that accumulates compressed bytes (matching Content-Length) and then optionally through a DecompressionStream('gzip') if the response Content-Type says so. The progress UI reads the compressed counter, so a 80 MB number doesn’t jump to 60% because the file decompressed faster than expected.

What we explicitly didn’t build

Web workers for parsing. The cost of postMessage on a 500 MB string — even with structuredClone — is roughly the cost of the parse itself, sometimes worse. We accepted the main-thread freeze during parse and put a spinner in front of it.

Auto-truncation everywhere. Initially everything longer than 100 chars got a “…” badge. That made it impossible to scan numeric IDs or short hex strings. We narrowed truncation to JSON string values specifically; numbers, booleans, and keys render in full.

A bigger-files claim. We could advertise file sizes the browser can’t actually parse, and most of the time people wouldn’t notice because most JSON they throw at us is well under that. We don’t, because we can’t honestly back it up — the browser parses it, not us — and pretending otherwise sets up users to find a hard limit at exactly the wrong time.

The honest numbers

On a 2023 M2 MacBook in Chrome 121:

  • 50 MB JSON. Parse + flatten: ~1 second. Scroll, fold, search: instant. This is the range where naïve DOM viewers already feel sluggish; we feel native.
  • 200 MB JSON. Parse + flatten: 4-7 seconds (mostly JSON.parse). After that: instant.
  • 500 MB JSON. Parse + flatten: 12-20 seconds. Works, but this is about where it tops out: past this point V8’s string limit starts rejecting the file outright, and no browser-based viewer handles that today.

So — we’re not the viewer that opens impossible files. We’re the viewer where everything the browser can parse stays fluid, where most other viewers have already given up. That’s a smaller story than “open any JSON, any size, in your browser,” and a more honest one.