100-MB-JSON-Stresstest: Worker-Durchsatz, Speicherobergrenzen und Toggle-Koaleszierung
Von Aerisium Core · ·
Eine 100-MB-JSON-Datei sollte keinen Browser-Tab zum Absturz bringen. Der naive Ansatz garantiert bei dieser Größenordnung das Scheitern: Parsen auf dem Hauptthread, Debouncen jedes Toggles, Halten des gesamten DOM im Speicher. Aber das Ersetzen jedes dieser Muster durch eine korrekte Alternative (Web Worker, Koaleszierungsreferenzen, DOM-Virtualisierung) erzeugt eine Architektur, die Worst-Case-Eingaben ohne sichtbares Ruckeln übersteht.
Dieser Artikel dokumentiert reale Messungen beim Laden und Manipulieren eines 102-MB-JSON-Payloads in einem browserbasierten JSON-Editor.
Testumgebung
| Parameter | Wert |
|---|---|
| CPU | Intel Core i5-8250U @ 1,60 GHz (4 Kerne, 8 logisch) |
| RAM | 8 GB |
| OS | Windows 10 22H2 |
| Browser | Chrome 149.0.7827.201 |
| Payload | 102,67 MB JSON, 1.601.887 Schlüssel |
Der Payload bestand aus 180.000 Objekten, jeweils mit einem id-Feld und einem 400-Zeichen data-String, verpackt in einem Array. Diese Struktur imitiert einen großen Log-Dump oder einen Datenbankexport.
Anfängliche Formatierung: Worker-Durchsatz
Die Datei wurde per Drag & Drop geladen. FileReader.readAsText() erzeugte einen Roh-String, der an einen Web Worker zum Parsen und Formatieren gesendet wurde. Der Handler des Workers wurde mit performance.now() instrumentiert, um die Parse-Zeit von der Stringify-Zeit zu isolieren:
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;
| Phase | Zeit |
|---|---|
| Gesamter Worker-Roundtrip | 2.463 ms |
JSON.parse() | 581 ms |
JSON.stringify() mit Einrückung | 409 ms |
Die verbleibenden ~1,5 Sekunden wurden durch die Schlüsselzählung (rekursiver Durchlauf aller 1,6 M Schlüssel) und eine Deep-Clone-Sortieroperation (sortObjKeys) verbraucht, wenn die Schlüsselsortierung aktiv war.
Die Gesamtzeit von 2,5 Sekunden liegt deutlich unter der “Seite reagiert nicht”-Schwelle des Browsers. Der UI-Thread blieb vollständig reaktionsfähig, da alle schweren Berechnungen im Hintergrundthread des Workers ausgeführt wurden.
Speicherallokation nach dem anfänglichen Parsen
Eine Heap-Aufnahme, die unmittelbar nach der Rückgabe des Ergebnisses durch den Worker erstellt wurde:
| Messung | Wert |
|---|---|
| JS Heap (anfängliche Formatierung) | 232 MB |
| Gesamter Tab-Speicher | 647 MB |
Ein 102-MB-String expandiert auf etwa das 4-fache seiner Dateigröße im Speicher aufgrund von JavaScripts UTF-16-Kodierung. Der verbleibende Overhead umfasst den Zustandsbaum des Editors, den formatierten Ausgabestring und V8s interne Objektdarstellung der geparsten Struktur. V8 allokiert Speicher für Hidden Classes, Property-Backing-Stores und Element-Backing-Stores für jedes Objekt im Array. Jedes der 180.000 Objekte trägt einen festen strukturellen Overhead zusätzlich zu den Schlüssel- und Wertbytes.
Toggle-Koaleszierung: Synchronisationsreferenz
Der naive zeitbasierte Ansatz (ein 50-ms-Trailing-Debounce) scheitert, weil er nur verzögert — er annulliert nicht. Jeder Toggle stellt eine separate Worker-Anfrage in die Warteschlange, und wenn der Worker 2+ Sekunden pro Zyklus beschäftigt ist, wächst die Warteschlange unbegrenzt.
Der korrekte Ansatz ist eine Synchronisationsreferenz:
// Wird synchron im Click-Handler gesetzt, wenn der Worker beschäftigt ist
pendingToggleRef.current = true;
// Wird im onmessage-Callback des Workers geprüft — sendet nur den Endzustand
if (pendingToggleRef.current) {
pendingToggleRef.current = false;
worker.postMessage({ ...latestState });
}
Es wird keine Warteschlange erstellt. Der Worker erhält maximal eine Anfrage pro Zyklus, die den endgültigen Toggle-Zustand des Benutzers überträgt.
Kontrollierter Spam: 2 Minuten anhaltender Missbrauch
Das Gedrückthalten der Eingabetaste auf dem Sort-Keys-Toggle für 2 Minuten am Stück erzeugte 22 saubere Zyklen:
| Metrik | Wert |
|---|---|
| Gesamte Worker-Antworten | 22 |
| Dauer | ~120 s |
| Durchschnittliche Zykluszeit | ~5,5 s |
| Spitzen-JS-Heap | 270 MB |
| JS Heap (nach idle GC) | 183 MB |
Jeder Zyklus bündelte Hunderte von Tastendrücken in einer einzigen Anfrage. Der Tab fror nie ein und überschritt nie 270 MB JS-Heap.
Alle 22 Antworten zeigten parseTimeMs=0, was bestätigt, dass jede Anfrage nach der ersten das gecachte Objekt des Workers traf. Die 2-4 Sekunden Zykluszeit wurden durch die Tiefenklonung und Sortierung dominiert, nicht durch JSON-Parsing.
Extremer Missbrauch: Ununterbrochenes Toggle-Klicken
Ein aggressiveres Szenario — schnelles Umschalten zwischen mehreren Toggle-Buttons (Sort Keys, Minify, Indent) über 2,5 Minuten — treibt die Architektur in einen anderen Fehlermodus. Die Koaleszierungsreferenz verhindert Warteschlangenbildung, erzwingt aber kein Ruheintervall. Solange der Benutzer weiter klickt, löst jeder abgeschlossene Zyklus sofort den nächsten aus und hält den Worker bei 100 % Auslastung ohne Leerlauffenster für die Garbage Collection.
Dies erzeugte über 50 aufeinanderfolgende Zyklen mit stetig steigendem Speicher:
| Metrik | Wert |
|---|---|
| Gesamte Worker-Antworten | 50+ |
| Dauer | ~150 s |
| Spitzen-JS-Heap | 3,2 GB |
Selbst bei Spitzenspeicher blieben alle UI-Steuerelemente visuell reaktionsfähig. Dropdowns öffneten und schlossen sich. Toggle-Schalter animierten zwischen aktivierten und deaktivierten Zuständen. Der Benutzer konnte mit jeder Schaltfläche oder jedem Menü interagieren.
Zwei Dinge brachen jedoch:
Statusleiste blieb auf “Processing” hängen. Nachdem der Benutzer mit dem Klicken aufgehört hatte und der Speicher auf 1,0-1,1 GB abfiel, zeigte die Statusleiste weiterhin “Worker: Processing…” ohne Erholung. Unter 3,2 GB Heap-Druck verwarf Reacts Scheduler das in startTransition eingewickelte Statusupdate der letzten Worker-Antwort und ließ isProcessing dauerhaft auf true. Der 300-ms-Abkühltimer wurde wahrscheinlich ebenfalls verworfen.
Das Ausgabefenster aktualisierte sich nicht mehr. Das formatierte JSON im Ausgabe-Editor spiegelte nie den endgültigen Toggle-Zustand wider. Der Worker hatte seine Antwort abgeschlossen und gesendet (in Konsolenprotokollen sichtbar), aber setOutputCode() war zusammen mit setIsProcessing(false) in startTransition eingewickelt, und beide wurden verworfen.
Dies ist ein bewusster Grenzfall, kein Fehler. Wenn ein Benutzer die Eingabetaste auf einem Toggle 2,5 Minuten lang gedrückt hält und seinen Browser auf 3,2 GB Heap bringt, arbeitet die Anwendung außerhalb ihrer Auslegungsparameter. Jede technische Gegenmaßnahme (Watchdog-Timer, erzwungene Abkühlung) würde die 99,9 % der normalen Interaktionen bestrafen, nur um sich gegen ein Szenario zu verteidigen, das ein Seitenneuladen in 2 Sekunden löst. Man kann keine Web-App so konstruieren, dass sie böswilligem Missbrauch standhält, ohne die Erfahrung für alle anderen zu verschlechtern.
Der Unterschied zwischen 270 MB und 3,2 GB ist kein Koaleszierungsfehler. Beide Szenarien bündeln Klicks in einzelne Anfragen pro Zyklus. Das 3,2-GB-Szenario führt einfach 50+ Zyklen hintereinander ohne Verschnaufpause aus und lässt kein Fenster für GC oder Reacts Scheduler. Dies liegt außerhalb der Auslegungsparameter der Anwendung.
Idle GC und Cache-Fehler-Wiederherstellung
Der Worker verwendet einen 60-Sekunden-Leerlauftimer, der sein internes gecachtes Objekt freigibt, um langfristige Speicherbindung zu verhindern. Nach 60 Sekunden Inaktivität löst das Klicken eines Toggles einen Cache-Fehler aus und der Hauptthread sendet den vollständigen Eingabestring erneut:
| Phase | Zeit |
|---|---|
| Cache-Fehler-Erkennung | ~0 ms |
| Code-Neusendung + Kalt-Parse | 1.486 ms |
| Stringify | 108 ms |
| Gesamte Wiederherstellung | 3.795 ms |
Nach Abschluss der Kaltrückgewinnung stabilisierte sich der Heap bei 183 MB, niedriger als die anfänglichen 232 MB, was darauf hindeutet, dass der Garbage Collector die zwischenzeitlichen Allokationen des früheren Toggle-Spams zurückgewonnen hatte.
Vollständiges Speicherprofil
| Aufnahme | Bedingung | JS Heap |
|---|---|---|
| 1 | Nach anfänglicher Formatierung | 232 MB |
| 2 | Nach 2 Min Toggle-Spam | 270 MB |
| 3 | Nach 60 s idle GC + Wiederherstellung | 183 MB |
| 4 | Nach Löschen | 34 MB |
Die Anwendung erholt sich nach dem Löschen der Eingabe auf nahezu Basis-Speicher, was bestätigt, dass keine Lecks im Worker-Lebenszyklus oder der Editor-Zustandsverwaltung vorhanden sind.
Wichtigste Erkenntnisse
-
Web-Worker-Delegation eliminiert UI-Einfrieren: selbst bei 102 MB bleibt der Hauptthread während der gesamten Verarbeitung reaktionsfähig.
-
Toggle-Koaleszierung verhindert Warteschlangenbildung:
pendingToggleRefbündelt N Klicks in 1 Anfrage. Ein zeitbasierter Debounce kann dies nicht garantieren, da Verzögerungen nicht annullieren. -
Spitzenspeicher hängt von der Missbrauchsdauer ab: bei anhaltendem 2-minütigem Spam mit natürlichen Pausen erreicht der Heap maximal 270 MB. Bei ununterbrochenem Klicken ohne Ruheintervalle (50+ aufeinanderfolgende Zyklen) kann der Heap 3,2 GB erreichen. Ein Abkühltimer zwischen den Zyklen würde diese Lücke schließen.
-
Cache-Fehler-Wiederherstellung ist graduell: der Worker signalisiert Cache-Ungültigkeit, der Hauptthread sendet die Eingabe erneut, und die Verarbeitung wird ohne Datenverlust abgeschlossen.
Das technische Muster, das dies ermöglicht, ist im Prinzip einfach, erfordert aber Disziplin bei der Durchsetzung: den Hauptthread mit nichts blockieren, jede Eingabevariation in genau einen ausstehenden Zustand koaleszieren und jedem Allokator einen Weg zurück auf Null geben.