Why I wrote this: Every tool on aijsons.com promises that your data never leaves the browser. That is not marketing — it is an architectural constraint. Users paste API logs, healthcare exports, and financial payloads that can exceed 50MB. The first version of our JSON Formatter called JSON.parse() on the main thread and the tab froze for 11 seconds on a 48MB file. Users assumed the site was broken and closed the tab. This article documents how we rebuilt the pipeline to stay responsive, what the hard limits are, and the benchmark numbers behind our design choices.
The Problem: Main-Thread Parsing Blocks Everything
JavaScript is single-threaded for DOM and most application logic. JSON.parse() on a 50MB string is CPU-bound and synchronous — the event loop cannot paint frames, handle clicks, or update a progress bar until parsing finishes.
I measured on a 2023 MacBook Air (M2, 16GB RAM), Chrome 124, with a synthetic JSON array of 500k objects (~48MB on disk):
| Operation | Main thread | Web Worker |
|---|---|---|
JSON.parse (48MB) | 10.8s (UI frozen) | 10.6s (UI responsive) |
JSON.stringify (parsed tree) | 6.2s | 6.0s |
| Tree render (DOM nodes) | 4.1s + layout jank | N/A (must run on main) |
Moving parse to a Worker does not make parsing faster — it makes the UI usable while parsing runs. That distinction matters for user trust.
Architecture: Three-Stage Pipeline
Our large-file pipeline on the Formatter and Tree Viewer tools follows three stages:
User selects file / pastes text
│
▼
Stage 1: Size check + user consent (>10MB warning)
│
▼
Stage 2: Web Worker — JSON.parse + optional validation
│
▼
Stage 3: Main thread — virtualized tree OR formatted text slice
Stage 1: File API and Honest Limits
We read files with FileReader.readAsText() or file.text() — the entire string still lands in memory once. There is no magic streaming read that avoids this for arbitrary JSON (JSON is not line-delimited like NDJSON). We show a confirmation dialog above 10MB:
const WARN_BYTES = 10 * 1024 * 1024;
const HARD_BYTES = 100 * 1024 * 1024;
if (file.size > HARD_BYTES) {
showError('Files over 100MB may crash this tab. Split the file or use a CLI tool.');
return;
}
if (file.size > WARN_BYTES) {
const ok = await confirmAsync(`This file is ${formatSize(file.size)}. Parsing may take 10–30 seconds. Continue?`);
if (!ok) return;
}
The 100MB hard cap is pragmatic — not a technical maximum. Chrome can parse larger files on machines with enough RAM, but mobile browsers and 8GB laptops OOM-kill tabs above ~120MB total heap for a single page. We prefer a clear error over a silent crash.
Stage 2: Web Worker Parse
Minimal worker we ship in production (simplified from our repo):
// json-parse.worker.js
self.onmessage = (e) => {
const { id, text } = e.data;
try {
const t0 = performance.now();
const value = JSON.parse(text);
self.postMessage({
id,
ok: true,
value,
ms: performance.now() - t0
});
} catch (err) {
self.postMessage({ id, ok: false, error: err.message });
}
};
Main thread posts the raw string; worker returns the parsed object. Critical detail: structured clone of a 48MB object back to the main thread duplicates memory briefly — peak usage can hit ~150MB during handoff. We mitigate by parsing once and immediately rendering from the worker result without keeping the original string reference:
text = null; // allow GC of input string after postMessage
For deeper Worker patterns (Comlink, transferable buffers), see our existing guide: JSON Parsing in Web Workers: A 2026 Performance Guide.
Stage 3: Virtualized Rendering (The Real Bottleneck)
Parsing in a Worker solved the freeze, but rendering 500k nodes as DOM elements still kills performance. Our Tree Viewer uses windowed rendering — only ~40 visible rows exist in the DOM at any time, backed by a flat index built from the parsed object.
Formatter behavior differs: for files >5MB we skip syntax-highlight tokenization on the full output and show pretty-printed text in a plain <textarea> with monospace font. Highlighting 50MB of HTML spans is worse than parsing.
Why We Do Not Upload to a Server
A server-side parse would be faster and simpler — throw the file at an API, get formatted JSON back. We rejected that for three reasons tied to our product, not ideology:
- Privacy commitments — healthcare and fintech users paste PII; a upload endpoint creates liability even with "we don't store" policies.
- Infrastructure cost — 50MB uploads at scale require bandwidth, timeouts, and abuse protection a static GitHub Pages site cannot host for free.
- Auditability — open-source client-side code lets security teams verify no network calls occur. Our tools do not call
fetch()on user input.
Client-side is harder engineering. It is also the reason the site exists.
Benchmark: Tool-by-Tool Behavior
How each aijsons.com tool handles large input (48MB test file, M2 MacBook, Chrome 124):
| Tool | Worker? | Usable result? | Notes |
|---|---|---|---|
| JSON Formatter | Yes | Yes (plain text) | No syntax highlight >5MB |
| JSON Validator | Yes | Yes | Reports line/col on error |
| JSON Compare | Yes | Partial | Diff summary only >10MB |
| Tree Viewer | Yes | Yes | Virtualized expand |
| JSON Minify | Yes | Yes | stringify in worker |
Compare tool degrades gracefully — full structural diff on 50MB would allocate a diff matrix we cannot afford in-browser. We show top-level key changes and recommend CLI tools for full audits.
Progress UX That Prevents Tab Closes
Ten seconds feels like forever without feedback. We show:
- Spinner with elapsed seconds (updated every 500ms via
setIntervalon main thread) - File size and estimated time based on prior runs (~200ms/MB parse on M2, ~400ms/MB on budget Android)
- Cancel button that terminates the Worker via
worker.terminate()
After adding elapsed time display, bounce rate on large-file sessions dropped ~35% in our GA4 data (April–May 2026, n≈2,400 sessions with file >10MB).
Mobile: Different Budget
Mobile Safari on iPhone 13 OOMs around 70MB total for our Formatter page. We lower the hard cap to 50MB when navigator.userAgent matches mobile and show a desktop recommendation. This is not elegant — feature detection of available heap (performance.memory) is Chrome-only and unreliable. User-agent gating is a blunt instrument that works.
When Client-Side Is the Wrong Tool
Honesty builds trust. We tell users to leave the browser for:
- NDJSON / JSON Lines logs >200MB — use
jq,fx, or stream parsers - Full structural diff on gigabyte files — use
jdor dedicated diff engines - Repeated querying — load into PostgreSQL JSONB with proper indexes (see our JSONB indexing guide)
Key Takeaways
- Web Workers move parse off the main thread — UI stays responsive; parse time is unchanged.
- Peak memory includes input string + parsed object + clone overhead — budget ~3× file size.
- Rendering dominates after parse; virtualize DOM or degrade features (no highlight) for large files.
- Set explicit size warnings (10MB) and hard caps (100MB desktop, 50MB mobile).
- Show progress with elapsed time — users wait if they know the tab is working.
- Client-side architecture is a privacy and cost choice, not a performance win over servers.