Skip to content
Work/Independent ProjectsIndependent · Solo
Front-end Performance · The browser is the whole runtime

JSONit

A JSON viewer, formatter, validator, repair and transform tool that runs entirely in the browser and works fully offline. It's a small app whose value is its performance architecture — built on a single, uncompromising rule.

installable PWA · fully offlineno server · data never leaves the browserVite · React 19 · TS · Web Worker
Role
Design + performance architecture — solo
Stack
Web Worker · CodeMirror 6 · IndexedDB · Workbox
Thesis
The parsed document never leaves the worker
Design target
tens of MB · millions of nodes
OwnershipDesign · SoloPerf architecture · SoloImplementation · SoloPerf claims are design targets · not benchmarked
01 — What it is

JSONit repairs, explores, searches, queries (JSONPath), transforms, diffs, and generates types from JSON — all client-side, all offline, installed as an app. Everything private: the data never leaves the browser.

But the feature list isn't the interesting part. Any of those is a weekend. The interesting part is that they all stay responsive on a 30-megabyte document — and that comes from one architectural commitment I refused to break, everywhere.

The whole app is one rule.
The big document never touches the main thread.

02 — The problem

Every browser JSON tool dies on large files the same, naïve way: JSON.parse a 30MB string, hand the giant object straight to React, render a tree of hundreds of thousands of nodes, and re-render the lot on every interaction. The main thread stalls; the tab jank-freezes; the toolbar stops responding to clicks.

The tempting move is to reach for one fix — "add virtualization" — and call it done. That solves the wrong half of the problem.

03 — Two costs, not one

The freeze isn't a single expense. It's two distinct ones that need two distinct fixes — and solving either alone still leaves you with a slow app. Naming them separately was the entire unlock.

Fig 1 · The freeze is two independent costs
Cost A · memory
Holding & moving the big object

The parsed object sits on the UI thread and gets structure-cloned across every boundary. GC pressure and copy cost — independent of how much you render.

fix: move it off the thread
Cost B · rendering
Painting a giant node tree

Hundreds of thousands of DOM nodes, re-rendered on expand / collapse / search. Layout and paint cost — independent of where the object lives.

fix: only ever render a window

solve one and the other still freezes the tab — you need both

"Add virtualization" only addresses Cost B. The parsed document is still on the UI thread, still cloned, still triggering GC. Correctly diagnosing two problems is what makes the design hold.
04 — Alternatives considered

Each of these fixes exactly one of the two costs — which is why none of them survived alone.

Virtualize the tree, parse on the main thread

Half a fix

Kills Cost B, ignores Cost A. The parse itself and the resident object still stall the UI thread — the app freezes before a single row renders.

Parse in a worker, post the whole object back

Half a fix

You paid to move it off-thread, then structure-clone the entire document back — reintroducing Cost A at the boundary. The clone of a 30MB object is its own multi-hundred-ms stall.

Ship it to a server for processing

Rejected

Fast, and against the whole point. People paste secrets, tokens, and customer data into JSON tools. Privacy — data never leaving the browser — is a product requirement, not a nice-to-have.

05 — The rule, and what follows from it

One commitment kills both costs at once, and everything else in the app is a consequence of holding it: the parsed document lives only inside the worker and is never cloned back to the main thread.

1

The document lives only in the worker

Parse, repair, search, JSONPath, transforms, and every tree edit run inside the worker. The huge parsed object is never posted back — a hardened RPC layer over postMessage is the only way to touch it. Cost A is gone: nothing large is ever cloned across the boundary.

2

The tree is windowed against the worker

The worker owns the tree's expansion state and builds the flat row list. The main thread pulls only a small slice of pre-rendered rows around the viewport — a screenful plus buffer, on demand. Expand, collapse, reveal-a-hit, re-format: none of them rebuild a giant list on the UI thread. Cost B is gone too.

3

History storage split so listing never loads bodies

Documents persist to IndexedDB with history bodies in a separate object store, so listing history never loads document contents. The current buffer auto-saves and restores on reload — a refresh never loses work.

A good rule doesn't just solve the problem —
it decides every question that comes after it.

06 — Architecture

The main thread holds a window. The worker holds the world.

The boundary is the design. On the left, the UI thread never holds more than a screenful of rows. On the right, the worker owns the entire document and its tree state. Between them, a narrow RPC that only ever carries small, bounded messages — requests and row windows, never the document itself.

Fig 2 · The worker boundary
Main thread · UI
CodeMirror 6 editor
Windowed TreeView
~1 screenful of rows + buffer
workerClient (RPC)
caches + requests row windows
never holds the whole document
postMessage
requests ↓
row windows ↑
the document
never crosses
Web Worker · the world
the parsed document
lives here, only here
parserepairsearchJSONPathtransformexpansion state
owns the flat row list
Because the worker owns expansion state, expand / collapse / search-reveal are computed where the data already is — and Format preserves your expansion instead of resetting it. The UI just asks for the rows it can see.
The editor · a deliberate downgrade above ~4MB

One more consequence of the rule. Above roughly 4MB the editor drops JSON syntax highlighting on purpose — CodeMirror's incremental highlighter can't keep multi-million-line documents tokenized without janky deep scrolling. The editor stays plain but smooth; the heavy work moves to the Tree / Query / Transform tabs, which run in the worker anyway.

editor mode · by size
// a smooth plain editor beats a highlighted frozen one
const highlight = docBytes < 4_000_000;
editor.setExtensions(highlight
  ? [jsonLang, jsonHighlight, ...base]
  : [...base]);   // plain, but never janks on deep scroll

A threshold, not a cliff: below it you get the full editing experience; above it you get one that stays usable. Choosing which feature to give up under load is a design decision, not a failure.

07 — Trade-offs
Chose

RPC indirection for every operation

The cost
Can't just read the object on the main thread — every access is a message. Accepted: it's the price of never cloning it. The client caches and requests windows to keep the chatter bounded.
Chose

No syntax highlighting on very large files

Not buying
A prettier editor that stutters. A smooth plain editor is the better product above the threshold — the color lives in the worker-backed Tree view anyway.
Chose

Repair on demand, never silently

Not buying
Auto-correcting input behind the user's back. Repair (single quotes, trailing commas, comments, Python literals, truncation) is an explicit action with undo — the user stays in control of their data.
08 — Outcome
design target from the architecturenot formally benchmarked
Responsive on tens-of-MB docs

A virtualized tree that handles millions of nodes without freezing the tab — because neither cost ever lands on the main thread.

Offline, private, installable

Full app shell + worker precached via a tuned Workbox config; data never leaves the browser. Refresh never loses the current buffer.

Honest scope: "tens of MB" and "millions of nodes" are design targets that follow from the architecture, not measured p50/p95 numbers — there's no formal benchmark harness yet. I'd rather state the claim's basis than dress a target as data.

09 — Lessons learned
Split the cost before you fix it. "Slow" was two problems wearing one symptom. Diagnosing memory-vs-render separately is what kept a partial fix from masquerading as a real one.
A boundary is only as good as your discipline about it. The worker boundary works because nothing large ever crosses it — one convenient "just post the object back" would have quietly reintroduced the whole problem.
Under load, choosing what to give up is the feature. Dropping highlighting above 4MB isn't a limitation — it's the decision that keeps the editor usable at scale.
Small commit count, deep design. The depth here isn't volume — it's one thesis executed consistently across parse, repair, search, transform, and every tree edit.
10 — If I rebuilt this today

The honest revision — mostly about proving the claims I currently only reason about.

Keep

The one rule, held everywhere

Document-in-the-worker, window-against-the-worker. It's the whole reason the app is fast; I wouldn't loosen it for any single feature's convenience.

Change

Build the benchmark harness first

Turn "tens of MB" and "millions of nodes" from design targets into measured p50/p95 render numbers across a fixed corpus — so the claims are data, and regressions are visible.

Consider

Streaming / incremental parse for the first paint

Even off-thread, a 30MB parse is a moment of latency. A streaming parser could render the first rows before the whole document is parsed — first paint decoupled from total size.

© 2026 Sukhcharan SinghCase study · JSONit