100MB JSON Stress Testing: Worker Throughput, Memory Ceilings, and Toggle Coalescing

By Aerisium Core · ·

A 100MB JSON file should not crash a browser tab. The naive approach guarantees failure at this scale: parse on the main thread, debounce every toggle, hold the full DOM in memory. But replacing each of those patterns with a correct alternative (Web Worker, coalescing refs, DOM virtualization) produces an architecture that survives worst-case input without visible jank.

This article documents real measurements from loading and manipulating a 102MB JSON payload in a browser-based JSON editor.

Test Environment

ParameterValue
CPUIntel Core i5-8250U @ 1.60GHz (4 cores, 8 logical)
RAM8 GB
OSWindows 10 22H2
BrowserChrome 149.0.7827.201
Payload102.67 MB JSON, 1,601,887 keys

The payload consisted of 180,000 objects, each with an id field and a 400-character data string, wrapped in an array. This structure mimics a large log dump or database export.

Initial Format: Worker Throughput

The file was loaded via drag-and-drop. FileReader.readAsText() produced a raw string, which was sent to a Web Worker for parsing and formatting. The worker’s handler was instrumented with performance.now() to isolate parse time from stringify time:


const parseStart = performance.now();

parsedObj = JSON.parse(code);

parseTimeMs = performance.now() - parseStart;

const stringifyStart = performance.now();

result = JSON.stringify(transformed, null, indent);

stringifyTimeMs = performance.now() - stringifyStart;
PhaseTime
Total worker round-trip2,463 ms
JSON.parse()581 ms
JSON.stringify() with indentation409 ms

The remaining ~1.5 seconds were consumed by key counting (recursive walk of all 1.6M keys) and a deep-clone sort operation (sortObjKeys) when key sorting was active.

The 2.5-second total is well within the browser’s “Page Unresponsive” threshold. The UI thread remained fully responsive because all heavy computation executed in the worker’s background thread.

Memory Allocation After Initial Parse

A heap snapshot taken immediately after the worker returned the result:

MeasurementValue
JS Heap (initial format)232 MB
Total tab memory647 MB

A 102MB string expands to approximately 4x its file size in memory due to JavaScript’s UTF-16 encoding. The remaining overhead includes the editor’s state tree, the formatted output string, and V8’s internal object representation of the parsed structure. V8 allocates memory for Hidden Classes, property backing stores, and element backing stores for every object in the array. Each of the 180,000 objects carries fixed structural overhead beyond the key and value bytes.

Toggle Coalescing: Synchronization Ref

The naive timing-based approach (a 50ms trailing debounce) fails because it only delays — it does not cancel. Each toggle enqueues a separate worker request, and when the worker is busy for 2+ seconds per cycle, the queue grows unbounded.

The correct approach is a synchronization ref:


// Set synchronously in the click handler when worker is busy

pendingToggleRef.current = true;

// Checked in the worker onmessage callback — send only the final state

if (pendingToggleRef.current) {
  pendingToggleRef.current = false;
  worker.postMessage({ ...latestState });

}

No queue is created. The worker receives at most one request per cycle, carrying the user’s final toggle state.

Controlled Spam: 2 Minutes of Sustained Abuse

Holding Enter on the Sort Keys toggle for 2 minutes straight produced 22 clean cycles:

MetricValue
Total worker responses22
Duration~120 s
Average cycle time~5.5 s
Peak JS Heap270 MB
JS Heap (after idle GC)183 MB

Each cycle collapsed hundreds of keystrokes into a single request. The tab never froze and never exceeded 270 MB of JS heap.

All 22 responses showed parseTimeMs=0, confirming every request after the first hit the worker’s cached object. The 2-4 second cycle time was dominated by the deep clone and sort operation, not by JSON parsing.

Extreme Abuse: Non-Stop Toggle Clicking

A more aggressive scenario — alternating between multiple toggle buttons (sort keys, minify, indent) as fast as possible for 2.5 minutes — pushes the architecture into a different failure mode. The coalescing ref prevents queue buildup, but it does not impose a rest interval. As long as the user keeps clicking, each completed cycle immediately triggers the next, keeping the worker at 100% duty cycle with no idle window for garbage collection.

This produced over 50 consecutive cycles with memory climbing steadily:

MetricValue
Total worker responses50+
Duration~150 s
Peak JS Heap3.2 GB

Even at peak memory, all UI controls remained visually responsive. Dropdowns opened and closed. Toggle switches animated between checked and unchecked states. The user could interact with any button or menu.

However, two things broke:

Status bar stuck on “Processing”. After the user stopped clicking and memory settled to 1.0-1.1 GB, the status bar continued showing “Worker: Processing…” with no recovery. Under 3.2 GB heap pressure, React’s scheduler dropped the startTransition-wrapped state update from the last worker response, leaving isProcessing permanently true. The 300ms processing cooldown timer was also likely dropped.

Output panel stopped updating. The formatted JSON in the output editor never reflected the final toggle state. The worker had completed and sent its response (visible in console logs), but setOutputCode() was wrapped in startTransition alongside setIsProcessing(false), and both were dropped.

This is a deliberate edge case, not a bug. If a user holds Enter on a toggle for 2.5 minutes straight and pushes their browser to 3.2 GB of heap, the application is operating outside its design parameters. Any engineering countermeasure (watchdog timer, forced cooldown) would penalize the 99.9% of normal interactions just to defend against a scenario that a page reload solves in 2 seconds. You cannot engineer a web app to survive malicious abuse without degrading the experience for everyone else.

The difference between 270 MB and 3.2 GB is not a coalescing failure. Both scenarios collapse clicks into single requests per cycle. The 3.2 GB scenario simply runs 50+ cycles back-to-back with zero breathing room, leaving no window for GC or React’s scheduler. This is outside the application’s design parameters.

Idle GC and Cache Miss Recovery

The worker employs a 60-second idle timer that frees its internal cached object to prevent long-term memory retention. After 60 seconds of inactivity, clicking a toggle triggers a cache miss and the main thread resends the full input string:

PhaseTime
Cache miss detection~0 ms
Code resend + cold parse1,486 ms
Stringify108 ms
Total refetch3,795 ms

After the cold refetch completed, the heap settled at 183 MB, lower than the initial 232 MB, indicating the garbage collector had reclaimed intermediate allocations from the earlier toggle spam.

Complete Memory Profile

SnapshotConditionJS Heap
1After initial format232 MB
2After 2 min toggle spam270 MB
3After 60s idle GC + refetch183 MB
4After clear34 MB

The application recovers to near-baseline memory after clearing the input, confirming no leaks in the worker lifecycle or editor state management.

Key Takeaways

  1. Web Worker delegation eliminates UI freezes: even at 102 MB, the main thread remains responsive throughout all processing.

  2. Toggle coalescing prevents queue buildup: pendingToggleRef collapses N clicks into 1 request. A timing-based debounce cannot make this guarantee because delays do not cancel.

  3. Peak memory depends on abuse duration: under sustained 2-minute spam with natural pauses, heap peaks at 270 MB. Under non-stop clicking with zero rest intervals (50+ consecutive cycles), heap can reach 3.2 GB. A cooldown timer between cycles would eliminate this gap.

  4. Cache miss recovery is graceful: the worker signals cache invalidation, the main thread resends the input, and processing completes without data loss.

The engineering pattern that enables this is straightforward in principle but requires discipline in enforcement: block the main thread from nothing, coalesce every input variation into exactly one pending state, and give every allocator a path back to zero.

JSONClear. Crafted without compromise by Aerisium.