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.

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_HEIGHTgive 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
transformor 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
requestAnimationFrameand 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:
- Preserve the visual anchor when collapsing above the viewport. Rows below shift upward; if
scrollTopis 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. - 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.
- 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.
- 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:
- Measure a baseline: first-render time, node count, scroll frame time;
- Collapse by default: expand two levels, show type and count in summaries;
- Virtual list: fixed row height, stable keys, viewport plus a small buffer;
- Move computation: parsing, search, and flattening into a Worker;
- Constrain highlighting: style visible rows only, avoid repainting the whole tree;
- 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.