JSON Toolkit

Why Big JSON Trees Stutter: Virtual Scrolling and Collapse Strategies

Bottom line: Stutter starts when DOM nodes are created, not when you scroll. Covers collapse-first rendering, virtual lists, search highlighting and Worker boundaries, with metrics.

Advanced~10 min readPublished 2026-10-31Updated 2026-10-31
Virtual scrolling and collapse strategy for a JSON viewer

Bottom line: split "slow" into parsing, node creation, layout and paint, and interaction, then measure each stage. Implement in this order — collapse by default, add a virtual list, move computation into a Worker, and constrain highlighting to visible rows. Jumping straight to a virtual list without collapsing only shrinks the visible set; the full-tree recompute still costs you.

The stutter usually comes from the hundreds of thousands of DOM nodes you created for those 5MB, not from the 5MB themselves.

1. Split "slow" into four stages

When a user says "it freezes on open," the problem can live in four completely different stages, each with a different remedy:

Stage Typical trigger Perceptible threshold Dominant cost
Parse and build objects Opening a file, pasting text Any task over 50ms Scanning strings, building the object graph
Create DOM nodes Rendering the whole tree at once Tens of thousands of nodes Allocating elements, text nodes, style objects
Layout and paint First display, scrolling Any frame over 16.7ms Geometry, reflow, repaint
Interaction response Expand, collapse, search Over 100ms feels delayed Re-flattening and re-rendering

The browser main thread does one thing at a time. If any stage occupies it for more than 50ms, scrolling and typing drop frames; several such tasks in a row and the page looks dead. The first optimization is therefore not requestAnimationFrame; it is measuring which stage is actually spending the time.

2. Node count is the real enemy

Tree viewers multiply node counts by design. A single JSON line usually becomes "indent + key + colon + value + comma + chevron," so a conservative estimate is 3 to 6 DOM nodes per line:

File size Approximate lines Tree-view DOM nodes (estimate) What one-shot rendering looks like
100 KB 2,500 10,000 Usually tolerable
1 MB 25,000 100,000 Clearly janky, possibly seconds of blank screen
5 MB 125,000 500,000 Very likely unresponsive
20 MB 500,000 Over 2 million Both memory and layout collapse

Each element is more than a few dozen bytes of object: it carries style rules, a layout box, and paint instructions. Once created, a node still costs layout and style work even if it is later hidden by a collapsed parent, as long as it has not actually been removed. "Render everything, then hide it" is not an optimization; it just postpones the cost to the next reflow.

3. Collapse first, virtualize second

Virtual scrolling solves "too many visible rows." Collapse solves "we should not need to see this many rows at all." The order matters: for a million-line document expanded by default, even a perfect virtual list must recompute the entire tree when the user clicks "collapse all."

A workable collapse strategy:

  • Expand only the first two levels by default. The root and its immediate fields are visible; deeper levels show a summary such as items: Array(12000);
  • Put type and count in the summary. Users checking "are the field names right" rarely need ten thousand records;
  • Cache rows per subtree. Each expanded node owns its flattened row array; collapsing discards it, and expanding recomputes only that subtree;
  • Never rescan the whole tree on every toggle. If expansion happens at row 3,000, replace only that range and keep the surrounding rows intact.
/**
 * Insert a subtree's rows after its parent instead of re-flattening the whole tree.
 * @param rows Current visible row array
 * @param parentIndex Index of the parent row
 * @param childRows Flattened rows of the subtree
 * @returns A new visible row array
 */
function replaceSubtreeRows(
  rows: TreeRow[],
  parentIndex: number,
  childRows: TreeRow[],
): TreeRow[] {
  const nextRows = rows.slice();
  nextRows.splice(parentIndex + 1, 0, ...childRows);
  return nextRows;
}

The pattern to avoid is "one expand, one full relayout." Re-walking the complete object graph on every click makes cost grow linearly with the document. With per-subtree caching, a single expansion costs only as much as that subtree.

4. Virtual lists: render only the viewport

Once collapse has reduced the row count, the remainder can still be tens of thousands of rows. A virtual list sets the container height to "total rows x fixed row height" and creates elements only for the viewport plus a small buffer above and below.

const ROW_HEIGHT = 24;
const OVERSCAN = 8;

/**
 * Compute the row range that the viewport should render.
 * @param rows All visible rows
 * @param scrollTop Current scroll offset of the container
 * @param viewportHeight Visible height of the container
 * @returns Rows to render, the start index, and the vertical offset
 */
function getViewportRows(
  rows: TreeRow[],
  scrollTop: number,
  viewportHeight: number,
): { visibleRows: TreeRow[]; startIndex: number; offsetTop: number } {
  const start = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - OVERSCAN);
  const end = Math.min(
    rows.length,
    Math.ceil((scrollTop + viewportHeight) / ROW_HEIGHT) + OVERSCAN,
  );

  const visibleNodes = rows.slice(start, end);
  return {
    visibleRows: visibleNodes,
    startIndex: start,
    offsetTop: start * ROW_HEIGHT,
  };
}

Non-negotiable constraints:

  • Row height must be fixed. Only then can scrollTop / ROW_HEIGHT give you the index directly. Allowing wrapped rows forces you to measure and cache every height, which adds an order of magnitude in complexity and jitter;
  • Offset with transform or absolute positioning. Do not push content down with a spacer element, or every scroll mutates layout;
  • Use stable keys. Key rows by path, not by array index, or expanding one row remounts every row after it;
  • Do arithmetic in the scroll handler only. Defer rendering to requestAnimationFrame and guard against duplicate scheduling;
  • Keep the buffer small. Five to ten rows on each side is enough; doubling the buffer doubles DOM nodes and style work.

With an 800-pixel viewport and 24-pixel rows, roughly 34 rows are visible, and even with the buffer you render about fifty. The underlying document can hold a hundred thousand rows while the DOM consistently holds a few hundred elements, keeping layout cost flat.

5. Collapse, expand, and scroll anchoring

Once the row index becomes the only coordinate, a few interaction details start to break:

  1. Preserve the visual anchor when collapsing above the viewport. Rows below shift upward; if scrollTop is not adjusted in the same commit, the content appears to jump. Record the anchor row's path and restore the scroll position using its new index.
  2. Expand ancestors before jumping. When a search hits a deep field, first expand every parent, then compute the target row index, then scroll and highlight. Skip the first step and the target row is not even in the visible array.
  3. Drive keyboard navigation from visible rows. Up and down move the row index; left and right expand and collapse. Never walk the whole tree inside a key handler.
  4. Batch updates into one frame. When expanding several levels in a row, merge the array changes and render once, or each level triggers a full reflow.

6. Search and highlighting: compute once, display a little

Search is the most commonly overlooked performance trap in a tree viewer. If every keystroke walks the whole object graph and rebuilds the result list, a hundred-thousand-node document will freeze instantly.

A layered approach:

  • Debounce input by 150-250ms and tag each search with a sequence number so stale results are discarded;
  • Scan in a Worker. Walking an object graph is pure computation with no DOM dependency, so it should not block typing;
  • Store only paths and row indices. Do not copy every matched node or pre-build highlighted HTML;
  • Highlight only visible rows. The viewport holds a few dozen rows; apply styles by matched path at render time;
  • Reuse "expand ancestors, then locate the index" for jumps, the same path used by clicking a chevron.

One security detail: keys and values in a tree viewer are user data. Building highlighted HTML from them requires escaping, or a string containing markup becomes XSS. A JSON viewer needs no rich text, so text nodes plus class names are both safer and faster.

7. Syntax highlighting: CodeMirror and Monaco are different tools

A tree view and a text editor are different rendering models, so "which is faster" is not a direct comparison:

Dimension CodeMirror 6 Monaco Editor
Positioning Modular editor core Full IDE editor
Rendering Viewport-based Viewport-based, but models and decorations are heavier
Parsing Lezer incremental parsing over changed ranges Monarch tokenizer, line by line
Large files Depends on extensions; light by default Feature-complete, higher memory and startup cost
Folding and search Separate extensions, loaded on demand Built in, with permanent overhead
Typical pitfall Too many extensions restore the cost Minimap, sticky scroll, and stacked decorations

Two practical conclusions:

  • A tree view does not need a full tokenizer. Give keys, strings, numbers, and booleans one color each with plain text nodes and CSS classes; that costs an order of magnitude less than running syntax highlighting;
  • Do not mount a tree view and an editor at the same time. Maintaining two models for one document doubles memory; destroy the old view instead of hiding it.

If an editor really belongs inside the viewer, CodeMirror 6 suits "read-only, large file, local highlighting," while Monaco suits completion, diagnostics, and multi-cursor editing. Measure first-paint time and resident memory before reading the feature list. For a deeper comparison of editors and CLI tools, see Editors, Command-Line Tools, and Automation.

8. Worker boundaries: move parsing and row building off the main thread

The other half of rendering jank is parsing on the main thread. For JSON over 1MB, parsing, formatting, and flat-row construction belong in a Web Worker; the main thread only takes rows and paints the viewport.

Workers are not free: postMessage uses structured clone, so sending a 50MB object graph back to the main thread builds a second copy and doubles the peak. The correct boundary is parse and flatten inside the Worker, return only the rows the renderer needs:

worker.onmessage = (event: MessageEvent<{ rows: TreeRow[] }>) => {
  rows = event.data.rows;
  requestAnimationFrame(() => renderViewport());
};

worker.postMessage({ text });

If the row data is still large, compress rows into parallel arrays (path, type, value offset), place them in an ArrayBuffer, and transfer ownership so the main thread does not pay for a copy. The cost is writing your own serializer, which is only worth it at millions of rows.

The rule for the boundary is simple: the main thread should do only work that must touch the DOM. Parsing, searching, sorting, formatting, and diffing can all move away. Rendering, scrolling, input, and focus stay on the main thread, and every frame must fit the work budget.

9. Metrics and implementation order

Optimization cannot stop at "it feels faster." The following set can go straight into an acceptance checklist:

Metric Target How to measure
First visible frame Under 300ms performance.now() from open to first paint
Long tasks None over 50ms PerformanceObserver watching longtask
Scroll frame time Steady under 16.7ms DevTools Performance recording
DOM elements in viewport Under 2,000 document.querySelectorAll('*').length
Expand / collapse response Under 50ms Timestamps around the interaction
Search over 100,000 rows Under 300ms, inside a Worker Time in the Worker and return the duration
Resident heap Under 3x the text size DevTools Memory heap snapshot

Measure with a real file rather than uniformly generated small objects; data shape matters, and long strings, deep nesting, and very wide arrays all produce different curves. For the object-graph ledger itself, see The JSON Memory Ledger.

Implement in this order:

  1. Measure a baseline: first-render time, node count, scroll frame time;
  2. Collapse by default: expand two levels, show type and count in summaries;
  3. Virtual list: fixed row height, stable keys, viewport plus a small buffer;
  4. Move computation: parsing, search, and flattening into a Worker;
  5. Constrain highlighting: style visible rows only, avoid repainting the whole tree;
  6. Add guardrails: above a threshold, offer "show the first N rows" or require a search filter.

Every step shares one goal: reduce the nodes and objects that must exist at the same moment, instead of asking the browser to work harder on them. The full background on layered strategy is in Parsing, Rendering, and Streaming Large JSON.

Can virtual scrolling fix parse-time jank?

No. Virtual scrolling only reduces DOM nodes. If parsing still happens on the main thread, the page still stutters. Move parsing into a Worker for documents over 1MB and treat the two problems separately.

Everything is expanded and it still stutters. What is wrong?

Either the virtual list is not actually active, or each expansion rescans the whole tree. Check whether off-screen rows still create DOM, and whether expansion recomputes only the affected subtree.

CodeMirror or Monaco for a JSON viewer?

Prefer CodeMirror 6 for read-only, large files, and local highlighting; its modular, on-demand loading is lighter. Choose Monaco when you need completion, diagnostics, and multi-cursor editing. With either one, avoid mounting a tree view and an editor for the same document at once.

Why is the scroll position wrong after a search jump?

Usually because the view scrolled before expanding. Expand all ancestors first, then set the scroll position from the new row index, and highlight last. Key rows by path rather than index so expanding does not shift every index.

FAQ

How do I find out which stage the stutter comes from?

Split "slow" into parsing and object building, DOM node creation, layout and paint, and interaction response, and time each stage with performance.now() from open to first paint. Then watch longtask with PerformanceObserver — any task over 50ms drops frames, and a DevTools Performance recording shows whether scroll frame time stays under 16.7ms.

Why does rendering the whole tree at once blank the screen?

A 5MB document is about 125,000 lines, and a tree view usually turns each line into 3 to 6 DOM nodes — roughly 500,000 elements at once. Each element also carries style rules, a layout box, and paint instructions, so several main-thread tasks over 50ms in a row make the page look dead. Nodes hidden by a collapsed parent keep costing layout work, so "render everything, then hide it" is not an optimization.

Should I send a 50MB object graph back from the Worker?

No. postMessage uses structured clone, so the receiving side builds a second copy and the peak doubles. The correct boundary is to parse and flatten inside the Worker and return only the rows the renderer needs; at millions of rows you can pack parallel arrays (path, type, value offset) into an ArrayBuffer and transfer ownership.