DEV Community

loggerhead turtle
loggerhead turtle

Posted on Originally published at treease.com

Keeping Large JSON Smooth: Update Only What Changed

Almost any implementation feels smooth when the JSON is small. The real challenge starts with large data: a real API response may contain deeply nested objects, long arrays, and many repeated structures. At that point, simply panning the canvas, editing one field, or switching between two documents can freeze the page.

While building Treease to work with 50 MB documents, I made a series of optimizations. This article collects the lessons behind them. It focuses on what to compute, read, and update, not on virtualization, or what to draw. For the rendering side, see How I Made a Canvas JSON Viewer Fast with Viewport Virtualization.

Process Only What You Need

A straightforward implementation often looks like this:

Full data → full layout tree → full graph → full Canvas scene
Enter fullscreen mode Exit fullscreen mode

The problem is that once any step works on the full data set, a local interaction can turn into global work. A better model keeps the data, layout, and current view separate:

Full data   →   full layout tree   →   subgraph   →  local Canvas objects
    ↑                    ↑                 ↑
search, edit    incremental updates    pan, zoom, click
Enter fullscreen mode Exit fullscreen mode

The benefit is that offscreen nodes still exist in the data and layout, so search, path navigation, and editing keep their context. More importantly, editing one field does not require recomputing unrelated data.

Two Layout Approaches

Consider this order JSON:

{
  "customer": {
    "name": "Maya",
    "email": "maya@example.com"
  },
  "orders": [
    { "id": "A-100", "total": 42 },
    { "id": "A-101", "total": 18 }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Balanced Layout

Many visualization tools treat nodes as positions that affect one another. They try to avoid overlap, spread nodes apart, and make the whole graph look balanced. The order JSON above might look like this:

                              ┌──▶ name
                ┌▶ [customer]-┤
                │             └──▶ email
[root] ─────────┤
                │             ┌──▶ [A-100]
                └▶ [orders] ─-┤
                              └──▶ [A-101]
Enter fullscreen mode Exit fullscreen mode

This graph is easy to read, but every position is decided in relation to all the others. customer, orders, and the rest all take part in the same calculation of what a more balanced graph should look like. If A-100 gets taller or gains a nested object, the layout has to reconsider every node to produce the next result. That makes this style of layout a poor fit for incremental or streaming updates.

Layered Layout

A layered layout does not decide positions by asking whether the whole graph looks balanced. It derives geometry directly from the JSON structure and reading order:

depth 0          depth 1           depth 2

[root] ────────▶ [customer]
  │                 ├─ name
  │                 └─ email
  │
  └────────────▶ [orders]
                     ├───────────▶ [A-100]
                     └───────────▶ [A-101]
Enter fullscreen mode Exit fullscreen mode

The layout follows these rules:

  • The root starts in the upper-left corner, and the graph grows to the right and downward.
  • Nodes at the same depth share the same X coordinate: X equals the rightmost boundary of the parent subtree plus a fixed horizontal gap.
  • The first node at each depth has a Y coordinate equal to the lowest boundary of the preceding sibling subtree plus a fixed vertical gap.
  • An edge starts from the matching row in the parent node and ends at the first row of the child node, rather than connecting the geometric centers of two nodes.

With these rules in place, the layout can support incremental and streaming updates, and it becomes easy to calculate the affected subgraph after an edit.

Local Changes Stay Local

Suppose order A-100 gets a new coupon field:

{
  "id": "A-100",
  "coupon": "SUMMER10",
  "total": 42
}
Enter fullscreen mode Exit fullscreen mode

In a layered layout, the result is easy to see. After inserting coupon:

[orders]
  ├─ [0] ─────────▶ [A-100]
  │                    ├─ id
  │                    ├─ coupon  ← new row
  │                    └─ total   ← moves down with its row
  │
  └─ [1] ─────────▶ [A-101]       ← later sibling in the same column moves down
Enter fullscreen mode Exit fullscreen mode

The affected area is clear and local:

coupon added
  → height of A-100
  → Y position of total
  → later sibling nodes at the same depth as A-100
Enter fullscreen mode Exit fullscreen mode

The layout propagates only when the change pushes later content out of the way, and only in the direction where that propagation is necessary.

Store Similar Data Together

Local layout is not enough on its own. Even if an algorithm updates only a few nodes, it can still waste time if finding those nodes means jumping through many scattered objects in memory.

The most intuitive in-memory form for JSON is an object tree:

root object
  ├─ pointer ──▶ customer object
  │                 ├─ pointer ──▶ name string
  │                 └─ pointer ──▶ email string
  │
  └─ pointer ──▶ orders array
                    └─ pointer ──▶ A-100 object
Enter fullscreen mode Exit fullscreen mode

It matches the way people think about JSON. But the machine has to keep following pointers: read one object, jump to an array, jump to another object, then jump to a string. Those objects may live in completely different parts of memory.

An alternative is to group records by type and keep records of the same type next to each other:

Node records: [0][1][2][3][4][5][6][7]...
Edge records: [0][1][2][3][4][5][6][7]...
Enter fullscreen mode Exit fullscreen mode

Both forms are O(N) when traversing N nodes, but locality can make their real-world costs very different:

  • Object tree: read a node, jump elsewhere for a child, then jump again for a string.
    • More pointer chasing, more cache-miss risk, and less opportunity for hardware prefetching.
  • Contiguous records: read adjacent nodes, edges, and rows in sequence.
    • A cache load is more likely to include data needed next, and the hardware can prefetch more effectively.

In practice, paths can be stored as compact parent-linked records instead of repeatedly copying full strings. Nodes and edges can be linked by numeric IDs. A table change can be located precisely by table ID and row number. Each update only needs to keep track of the nodes and rows it actually touched.

Query Only What Is in View

Virtualization prevents offscreen content from becoming Canvas objects, but it does not solve another common trap: drawing only a few dozen elements after first scanning hundreds of thousands of them.

The answer is a spatial grid. Split the canvas into cells and build a spatial index for the laid-out nodes and edges. When the user pans or zooms:

  1. Expand the viewport by a small buffer.
  2. Query the grid cells covered by that area.
  3. Collect nearby node and edge candidates.
  4. Run an exact intersection check.
  5. Update the current Canvas scene.

The core idea is simple: narrow the candidate set before doing expensive work.

Keep State in Its Own Scope

Locality is not only about space. It is also about the scope of state.

Imagine scrolling to item 4,000 in a navigation list with 10,000 children. The wrong approach is to read every item, create every UI element, and update the whole column:

scroll
  → read 10,000 items
  → create 10,000 list items
  → update the whole column
Enter fullscreen mode Exit fullscreen mode

A better approach binds the data, rendering, and state to the current window of the current path:

scroll near item 4,000
  → request items 3,980 through 4,020
  → update only that window
Enter fullscreen mode Exit fullscreen mode

The same is true when switching between documents. Each document should own its own editing state, graph state, and background tasks:

Document A: A's editing state, graph state, background tasks
Document B: B's editing state, graph state, background tasks
Enter fullscreen mode Exit fullscreen mode

When switching to B, activate the state B already has. Do not reread the full document from global state, parse it again, and recreate the graph.

Every data read, asynchronous task, and UI update should know which document, path, and window it belongs to. The key is to define the smallest useful scope and keep state inside it.

Performance Comes from Locality

The goal is not to invent an algorithm that can somehow hold an enormous JSON document. It is to give every user action a small, clear working set:

User action Data to process
Edit one field That field, its card, and the later layout it affects
Pan the canvas Nodes, rows, and edges near the viewport
Scroll navigation The visible window for the current path
Find a field The target and a small amount of context
Switch documents The saved state that belongs to the target document

The key is not processing data faster. It is knowing exactly which data needs work right now.

Top comments (0)