Every JSON viewer I've tried does the same thing: it pretty-prints. Add indentation, color the keys, fold the brackets. Useful for a 10-line config — useless for the actual data shape developers deal with every day, which is an array of objects.
A pretty-printed 50-row API response is still 500 lines you scroll top-to-bottom. You can't say "show me only admins" without re-reading every record. You can't sort by date. You can't search across rows.
That's not a viewer problem — that's the wrong format. Arrays of objects want to be tables:
┌────┬───────┬───────┬────────┐
│ ID │ NAME │ ROLE │ ACTIVE │
├────┼───────┼───────┼────────┤
│ 1 │ Alice │ admin │ ● Yes │
│ 2 │ Bob │ user │ ● Yes │
│ 3 │ Carol │ user │ ● No │
│ 4 │ Dave │ admin │ ● Yes │
└────┴───────┴───────┴────────┘
Sortable columns. Searchable rows. Click any row to expand the full nested detail. Same data — but you scan it instead of read it.
So I built prettyjsonxml.com — a JSON and XML viewer that turns arrays into real tables (plus a foldable tree view, format, minify, base64 image preview). One HTML file. No backend. No build step. Runs entirely in the browser; your data never leaves your machine.
What I didn't expect: making the table view actually fast on a 9 MB API response was a much harder problem than it looked. Here's the unedited story.
The naive version: works, then doesn't
V1 was straightforward:
const data = JSON.parse(text);
renderTable(data);
function renderTable(items) {
const tbody = document.querySelector('tbody');
items.forEach(row => {
const tr = document.createElement('tr');
// ... build cells
tbody.appendChild(tr);
});
}
Beautiful for the 5-row examples I tested. Then I pasted a real API response. The browser froze for 1.5 seconds.
The freeze had two sources:
-
JSON.parseon 9 MB blocks the main thread for ~500 ms - Creating 30,000 × 2 rows (main + detail) = 60,000 DOM nodes blocks for another ~1500 ms
You can't make either fast, but you can make them not freeze the UI.
Phase 1: content-visibility: auto — the one-line win
Modern browsers will skip layout for off-screen content if you tell them they can. One CSS rule:
.data-table tr.row-main {
content-visibility: auto;
contain-intrinsic-size: auto 40px;
}
content-visibility: auto tells the browser: "don't bother computing layout for this element until it's about to scroll into view." contain-intrinsic-size gives it a placeholder height so the scrollbar still represents the full document.
Total render time didn't change — the work still happens — but perceived performance jumped because the browser is free to paint visible parts first. Criminally underused. Works on ~95% of modern browsers.
Phase 2: Web Workers for JSON.parse — the counter-intuitive lesson
The next freeze was JSON.parse itself. Conventional wisdom: move expensive parsing off the main thread with a Web Worker.
const worker = new Worker(URL.createObjectURL(new Blob([`
self.onmessage = (e) => {
const parsed = JSON.parse(e.data);
self.postMessage(parsed);
};
`], { type: 'application/javascript' })));
worker.postMessage(largeJsonString);
worker.onmessage = (e) => render(e.data);
Done. Main thread stays responsive. Right?
It actually felt slower.
Here's why: when the worker sends the parsed object back via postMessage, the main thread has to structured-clone the entire object graph to receive it. For a 30,000-object array, that clone is 300–500 ms — also on the main thread.
So I'd successfully moved 500 ms of JSON.parse off the main thread, and added 400 ms of structured-clone onto it. Net win: ~100 ms. And the user perceives the freeze later in the flow, after they clicked a button expecting an instant result.
Web Workers are for CPU-bound work whose result doesn't have to come back. When 95% of the workload is the parsed object itself, structured-clone cost dominates the benefit.
I kept the worker for Format / Minify (output is a string, cheap to clone). For the parse-then-render flow, it was barely net-positive. The real fix was elsewhere.
Phase 3: Virtual scrolling — the actual fix
For 30,000-row tables, you don't render 30,000 rows. You render the ~50 the user can see, and swap them as they scroll.
The gotcha with <table>: you can't position: absolute rows (table layout doesn't allow it). Instead, use spacer rows:
<table>
<thead>...</thead>
<tbody>
<tr style="height:850px"></tr> <!-- spacer for rows above viewport -->
<tr>row 21</tr>
<tr>row 22</tr>
...
<tr>row 70</tr>
<tr style="height:1200px"></tr> <!-- spacer for rows below viewport -->
</tbody>
</table>
On every scroll event, recompute which rows are visible and swap them in:
function onScroll() {
const tbodyRect = tbody.getBoundingClientRect();
const viewTop = Math.max(0, -tbodyRect.top);
const viewBottom = viewTop + scrollContainer.clientHeight;
const startRow = Math.max(0, Math.floor(viewTop / ROW_HEIGHT) - 10);
const endRow = Math.min(items.length, Math.ceil(viewBottom / ROW_HEIGHT) + 10);
// Remove old visible rows, build new ones from items[startRow..endRow]
// Adjust spacer heights so scrollbar position stays correct
}
For a 30,000-row JSON array, this goes from "tab completely unresponsive" to "smooth 60 fps scroll." Even at more modest sizes — say a few hundred rows — the win is that search and sort become instant because they now operate on a JavaScript array, not by walking thousands of DOM nodes.
The bug I lost 20 minutes to: my virtual scroller listened to window.scroll. But my page had body { overflow: hidden } and <main> { overflow: auto } — so window.scroll never fired. The actual scroll events came from <main>.
// Walk up to find the nearest ancestor that actually scrolls
function findScrollContainer(el) {
let p = el.parentElement;
while (p && p !== document.body) {
const oy = getComputedStyle(p).overflowY;
if ((oy === 'auto' || oy === 'scroll') && p.scrollHeight > p.clientHeight) return p;
p = p.parentElement;
}
return window;
}
Always resolve the scroll container at runtime. Don't assume it's window.
The textarea trap
One more freeze I didn't expect: assigning a 9 MB string to <textarea>.value blocks the main thread for ~300–500 ms by itself. The browser has to compute layout for the text content even though most of it is below the fold.
Fix: for files > 5 MB, leave the textarea empty and show a styled "loaded state" panel instead.
if (file.size > 5 * 1024 * 1024) {
editor.value = '';
showLoadedOverlay(file.name, file.size);
} else {
editor.value = text;
}
The data still parses and renders into the viewer — it's just not in the editable textarea. For 9 MB files no one wants to hand-edit anyway.
What I'd do differently
- Start with virtual scrolling. Don't add it as Phase 3. It's the only thing that scales. Everything else is polish.
- Question the "move it to a Worker" reflex. Worker is great for compute you don't need back. Bad for parse-then-clone flows.
-
Use
content-visibility: autoeverywhere it applies. It's basically free. - Test with real production data early. My 5-row test cases hid every interesting bug.
The single-file thing
I kept arguing with myself whether to add a build step. "Just bundle it, just split into modules, just add TypeScript…" Every prototype of that was technically cleaner and materially worse — now I had to host more files, worry about cache busting, maintain a toolchain.
For a tool whose entire pitch is "100% in your browser, no server," shipping it as one HTML file you can Save As and run offline is the right product decision. Pragma over purity.
Final: ~225 KB single HTML, no dependencies, no build, served as-is from Cloudflare Pages.
If you want to try it: prettyjsonxml.com — paste any JSON or XML, view as a sortable table or foldable tree. Built it because I needed it. Sharing it because maybe you do too.
What perf tricks have you used for big-data UIs? Always curious about the ones that surprised people.
Top comments (0)