DEV Community

Cover image for How I Render 1 Million JSON Nodes Without Blocking the Browser
Vo Thanh Dat
Vo Thanh Dat

Posted on

How I Render 1 Million JSON Nodes Without Blocking the Browser

Rendering large data structures in the browser is always a challenge. When building react-obj-view, a library for visualizing massive JSON objects, I faced a performance wall. How do you render a JSON tree with 1 million nodes, support expand/collapse, and keep the UI butter-smooth at 60fps?

Here is the story of my journey, from the naive approach to a highly optimized solution using cumulative indexing and async time-slicing.

Attempt 1: Flatten to Array (The Standard Approach)

My first attempt was the most obvious one. To use virtualization libraries like react-window or react-virtuoso, you need a flat array of items.

So, I implemented a simple Depth-First Search (DFS) that traversed the JSON tree and produced a flat array of visible nodes.

// Conceptual implementation
function flattenTree(node) {
  let list = [node];
  if (node.expanded) {
    for (let child of node.children) {
      list = list.concat(flattenTree(child));
    }
  }
  return list;
}
Enter fullscreen mode Exit fullscreen mode

The Problem:
It worked fine for small objects. But for a 1MB JSON file with 100k lines? Disaster.
Every time the user clicked "Expand", I had to re-traverse the entire visible tree to generate a new array.

  • Time Complexity: O(N) where N is the number of visible nodes.
  • Result: Expanding a node near the root caused a noticeable freeze. The browser would hang while recalculating the array.

Attempt 2: Flatten to Linked List (The Optimization)

I realized I was wasting time re-processing unchanged parts of the tree. If I expand a node, the nodes before it and after it in the list don't change.

So, I switched to a Linked List.
When a node expands, I generate a linked list segment for its children and just "stitch" it into the main list.

The Benefit:

  • I could re-use previous results.
  • Expanding was faster because I didn't need to touch the entire tree, just the local neighborhood.

The Catch:
Virtualization libraries need Random Access (access by index, e.g., getItem(5000)).
Linked Lists are O(N) for random access. To support react-window, I still had to convert my beautiful linked list back into a flat array (or an index map) to lookup items by index.
So I was back to square one: one more step was required, and that step (flattening to array for indexing) was still expensive.

The Breakthrough: Index by Cumulative Child Count

I realized I was fighting against the tree structure. Why flatten it at all?
The problem is mapping a linear index (e.g., row 5000) to a specific node in the tree efficiently.

I came up with a solution: Cumulative Child Indexing.

Instead of flattening, I keep the tree structure. Each node in my state tree stores a childCount — the total number of visible descendants it has.

type WalkingResult = {
  // ...
  childCount: number; // Total visible descendants
  childOffsets: number[]; // Cumulative count for binary search
}
Enter fullscreen mode Exit fullscreen mode

How it works:
When react-window asks for "Item 5000":

  1. Start at the root.
  2. Look at the children. We know how many descendants each child has.
  3. Use Binary Search on the childOffsets to find which child contains the 5000th item.
  4. Subtract the offset and recurse down.

The Result:

  • Access Time: O(log N) (proportional to tree depth). For a balanced tree, this is instant.
  • Expand/Collapse: O(depth). When a node expands, we just update the childCount of its ancestors. No massive array re-allocation.
  • Memory: We only store state for visited nodes.

This was the game changer. I could now jump to any index in a 1-million-node tree instantly.

The Final Polish: Async Walking

There was one last problem. Even with the efficient structure, the initial render or an "Expand All" action required visiting every single node to calculate the initial childCount.
For 1 million nodes, just the loop to count them would block the JavaScript main thread for seconds, freezing the browser.

The Solution: Async Time-Slicing

I implemented an Async Walker using a Generator function.

const walkingAsync = function* (value, key, config, expandDepth, iterateSize = 100000) {
    let context = getContextDefault(config, expandDepth);
    try {
        while (true) {
            // Process a chunk of nodes
            context.iterateCounter = iterateSize;
            let state = walkingInternal(/*...*/)

            // Yield control to the browser to render a frame
            yield state

            if (state.iterateFinish) return;
        }
    } catch (e) {
        console.error("Error in walkingAsync", e);
    }
}
Enter fullscreen mode Exit fullscreen mode

How it works:

  1. The walker processes iterateSize nodes (e.g., 10,000).
  2. It yields the current partial state.
  3. The UI renders a loading spinner or the partial tree.
  4. requestAnimationFrame or setTimeout schedules the next chunk.

This ensures the browser remains responsive. The user can scroll, click, or cancel the operation while the massive tree is being calculated in the background.

How Cumulative Child Count Enables Time-Slicing

You might ask: Why not just time-slice the "Flatten to Array" approach?

The problem with flattening is that it's often an "all-or-nothing" operation, or at least very hard to pause. If you stop halfway through flattening a tree into an array, you have a broken array. Resuming requires complex state management to know exactly where you left off and where to append the next batch of items.

With Cumulative Child Indexing, the tree structure itself is the state.

  1. State Persistence: Each node stores its own childCount and childOffsets. When we pause the walker (yield), the partial tree remains valid.
  2. No Re-computation: When we resume, we don't need to re-calculate the parts we've already visited. We just pick up the traversal exactly where we left off, updating the counts of the current path.
  3. Partial Rendering: Because the structure is a tree, we can even render the partial results. If the walker has only finished the first 50% of the tree, the root node will reflect that count. react-window will just see a smaller list, and as the walker continues, the list grows dynamically.

This synergy between the data structure (Tree with Cumulative Counts) and the algorithm (Async Generator) is what makes the solution robust.

The Result: 2 Million Nodes

So, how does it perform?
I tested this with a generated JSON object containing 2,000,000 nodes.

Benchmark Result

  • First Render Blocking Time: ~20ms
  • Total Walking Time: ~523ms (background)
  • FPS: Consistently 60fps during the walk.

The user sees the initial content almost immediately, and the scrollbar grows as the rest of the tree is discovered in the background. No freezing, no jank.

Conclusion

Rendering 1 million nodes isn't just about raw speed; it's about smart data structures and respecting the main thread.

  1. Don't flatten if you don't have to. Trees are powerful.
  2. Use metadata (like subtree counts) to navigate complex structures efficiently.
  3. Yield control to the browser for long-running tasks.

You can check out the full implementation in react-obj-view.

Links

Top comments (0)