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.
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.
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.
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.
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.
Hundreds of thousands of DOM nodes, re-rendered on expand / collapse / search. Layout and paint cost — independent of where the object lives.
solve one and the other still freezes the tab — you need both
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 fixKills 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 fixYou 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
RejectedFast, 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.
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.
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.
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.
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.
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.
row windows ↑
never crosses
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.
// 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.
RPC indirection for every operation
No syntax highlighting on very large files
Repair on demand, never silently
A virtualized tree that handles millions of nodes without freezing the tab — because neither cost ever lands on the main thread.
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.
The honest revision — mostly about proving the claims I currently only reason about.
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.
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.
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.