DEV Community

Cover image for Pretty-print isn't enough. I built a JSON & XML viewer with a real table view.
Aleksandre Mtchedlishvili
Aleksandre Mtchedlishvili

Posted on

Pretty-print isn't enough. I built a JSON & XML viewer with a real table view.

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  │
└────┴───────┴───────┴────────┘
Enter fullscreen mode Exit fullscreen mode

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);
  });
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. JSON.parse on 9 MB blocks the main thread for ~500 ms
  2. 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;
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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: auto everywhere 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)