Need help with your JSON?
Try our JSON Formatter tool to automatically identify and fix syntax errors in your JSON. JSON Formatter tool
Viewport-Based Rendering for Large JSON Trees
If your JSON viewer freezes when a user expands a large object or a long array, the usual problem is simple: the UI is trying to mount every visible node at once. Viewport-based rendering, also called windowing or virtualization, fixes that by rendering only the rows near the current scroll position while the rest of the tree stays as lightweight data.
For JSON trees, the hard part is not the scroll container. It is keeping a flattened list of expanded nodes, handling variable row heights, and preserving usable keyboard navigation while rows are constantly mounting and unmounting.
Why Large JSON Trees Break So Easily
A naive tree renderer walks the JSON object recursively and creates a DOM element for every expanded property and array item. That works for small payloads, but it falls apart once one expand action reveals hundreds or thousands of rows.
- Initial expand cost spikes: the browser has to create, style, and lay out too many nodes in one frame.
- Memory usage climbs: every mounted row adds DOM overhead, event handling, and layout work.
- Scrolling gets janky: the browser keeps repainting a very tall, very busy tree instead of a small moving window.
What Actually Gets Virtualized
You do not virtualize the nested JSON structure directly. You virtualize a flat array of currently visible rows. Each row represents one node in the expanded tree and carries enough metadata to render it correctly.
- Keep the original JSON data untouched.
- Track expansion state separately, usually in a set keyed by stable node IDs.
- Flatten only the nodes that should currently appear on screen.
- Give that flat list to a virtualizer that decides which indexes to mount.
- Render those rows with indentation, toggles, previews, and ARIA state.
In practice, the stable ID matters more than most teams expect. A JSON Pointer style path such as root/users/42/email is much safer than a transient UI index because expansion state, search results, and scroll restoration all depend on it.
Flatten Only Expanded Nodes
type JsonValue =
| string
| number
| boolean
| null
| { [key: string]: JsonValue }
| JsonValue[];
type VisibleNode = {
id: string;
depth: number;
label: string;
kind: "object" | "array" | "string" | "number" | "boolean" | "null";
expandable: boolean;
expanded: boolean;
preview: string;
};
function flattenVisibleTree(value: JsonValue, expanded: Set<string>): VisibleNode[] {
const rows: VisibleNode[] = [];
function visit(node: JsonValue, id: string, depth: number, label: string) {
const isArray = Array.isArray(node);
const isObject = typeof node === "object" && node !== null && !isArray;
const kind = isArray ? "array" : isObject ? "object" : node === null ? "null" : typeof node;
const expandable = isArray || isObject;
const expandedHere = expandable && expanded.has(id);
rows.push({
id,
depth,
label,
kind,
expandable,
expanded: expandedHere,
preview: isArray
? "[...]"
: isObject
? "{...}"
: String(node),
});
if (!expandedHere) return;
if (isArray) {
node.forEach((child, index) => {
visit(child, id + "/" + index, depth + 1, String(index));
});
return;
}
if (isObject) {
Object.entries(node).forEach(([key, child]) => {
visit(child, id + "/" + key, depth + 1, key);
});
}
}
visit(value, "root", 0, "root");
return rows;
}A Practical Rendering Pattern
A scalable JSON tree usually follows this flow:
- Derive a flat visible-row array from the JSON data and the current expansion set.
- Pass that row count to the virtualizer.
- Render only the returned virtual rows inside one spacer element whose height matches the full tree.
- Indent rows from their depth value instead of nesting DOM containers for every branch.
- Update expansion state without recreating unrelated rows when possible.
This keeps the DOM size tied to the viewport, not to the total number of expanded nodes. That is the core win.
Virtualize the Flat Row List
const rowVirtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => containerRef.current,
estimateSize: () => 28,
overscan: 10,
});
return (
<div ref={containerRef} style={{ height: 560, overflow: "auto" }}>
<div style={{ height: rowVirtualizer.getTotalSize(), position: "relative" }}>
{rowVirtualizer.getVirtualItems().map((item) => {
const row = rows[item.index];
return (
<div
key={row.id}
data-index={item.index}
ref={rowVirtualizer.measureElement}
style={{
position: "absolute",
transform: "translateY(" + item.start + "px)",
width: "100%",
}}
>
<TreeRow row={row} />
</div>
);
})}
</div>
</div>
);Current Implementation Notes
Current virtualizer libraries are better at dynamic content than the older fixed-row approach, but JSON trees still need explicit guardrails.
- Start with an estimate, then measure: current TanStack Virtual APIs support estimated row sizes and on-mount measurement through helpers such as
measureElement. That is usually the right fit for JSON rows whose height changes with wrapping, inline previews, or badges. - Keep overscan modest: a small buffer above and below the viewport smooths wheel and keyboard navigation, but too much overscan recreates the DOM pressure you were trying to remove.
- Expect scroll compensation work: expanding a node above the viewport changes total height. If the virtualizer adjusts for that change, scroll position feels stable. If it does not, the tree appears to jump.
- Use CSS helpers as a complement, not a replacement:
content-visibility: autocan reduce paint and layout work for off-screen content, but the off-screen nodes still exist in the DOM, so it does not solve the core node-count problem that virtualization solves.
Common Failure Modes
- Expand is still slow: flattening the visible tree on every toggle can become the next bottleneck. Cache descendant ranges or update only the affected slice when extremely large branches open.
- Rows jump after measuring: mixed-height content needs a realistic size estimate. If your first estimate is far from the measured size, users will feel that correction.
- Search feels broken: browser find-in-page only sees mounted DOM rows. Large virtual trees usually need their own search index plus a way to expand and scroll to the matching node.
- Selection disappears while scrolling: keep focus state separate from DOM presence and restore it when the selected row remounts.
Accessibility Checklist for a Virtual Tree
A large JSON tree is still a tree widget, even when most rows are not mounted. If you want reliable keyboard and screen-reader behavior, implement the semantics deliberately instead of treating the widget as a styled list.
- Use tree semantics such as
tree,treeitem, and grouped child containers. - Expose expansion state with
aria-expandedon expandable rows. - Support the expected arrow-key model: up, down, left, right, home, and end.
- Keep a logical focused item even when the DOM row unmounts, often with roving tab index or
aria-activedescendant. - Announce depth, position, and collapsed or expanded state in the rendered row label.
When Viewport-Based Rendering Is Worth It
Use it when one realistic user action can reveal far more rows than the screen can display, or when you need smooth navigation through logs, configs, traces, or API payloads that routinely grow into the thousands.
If your viewer only handles a few hundred fixed-height rows, plain rendering is often simpler and easier to debug. Virtualization adds coordination cost, so it should buy you a real performance margin.
Bottom Line
The winning approach for large JSON trees is consistent across frameworks: flatten the expanded tree into stable row data, virtualize that flat list, measure dynamic heights carefully, and treat accessibility as a first-class requirement. Once you do that, a JSON viewer can stay fast even when the underlying payload is not small or polite.
Need help with your JSON?
Try our JSON Formatter tool to automatically identify and fix syntax errors in your JSON. JSON Formatter tool