Tests de Stress JSON de 100 Mo : Débit des Workers, Plafonds Mémoire et Fusion de Bascules
Par Aerisium Core · ·
Un fichier JSON de 100 Mo ne devrait pas faire planter un onglet de navigateur. L’approche naïve garantit l’échec à cette échelle : analyse sur le thread principal, debounce de chaque bascule, maintien du DOM complet en mémoire. Mais remplacer chacun de ces modèles par une alternative correcte (Web Worker, références de fusion, virtualisation du DOM) produit une architecture qui survit à la pire entrée sans blocage visible.
Cet article documente des mesures réelles du chargement et de la manipulation d’un payload JSON de 102 Mo dans un éditeur JSON basé sur navigateur.
Environnement de Test
| Paramètre | Valeur |
|---|---|
| CPU | Intel Core i5-8250U @ 1,60 GHz (4 cœurs, 8 logiques) |
| RAM | 8 Go |
| OS | Windows 10 22H2 |
| Navigateur | Chrome 149.0.7827.201 |
| Payload | 102,67 Mo JSON, 1 601 887 clés |
Le payload consistait en 180 000 objets, chacun avec un champ id et une chaîne data de 400 caractères, enveloppés dans un tableau. Cette structure imite un vidage de logs volumineux ou une exportation de base de données.
Formatage Initial : Débit du Worker
Le fichier a été chargé par glisser-déposer. FileReader.readAsText() a produit une chaîne brute, qui a été envoyée à un Web Worker pour analyse et formatage. Le gestionnaire du worker a été instrumenté avec performance.now() pour isoler le temps d’analyse du temps de stringify :
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 | Temps |
|---|---|
| Aller-retour total du worker | 2 463 ms |
JSON.parse() | 581 ms |
JSON.stringify() avec indentation | 409 ms |
Les ~1,5 secondes restantes ont été consommées par le comptage de clés (parcours récursif de toutes les 1,6 M de clés) et une opération de tri par clonage profond (sortObjKeys) lorsque le tri des clés était actif.
Le total de 2,5 secondes est bien en dessous du seuil « Page ne répond pas » du navigateur. Le thread d’UI est resté entièrement réactif car tous les calculs lourds ont été exécutés dans le thread d’arrière-plan du worker.
Allocation Mémoire Après l’Analyse Initiale
Un instantané du heap pris immédiatement après le retour du résultat par le worker :
| Mesure | Valeur |
|---|---|
| JS Heap (formatage initial) | 232 Mo |
| Mémoire totale de l’onglet | 647 Mo |
Une chaîne de 102 Mo se dilate à environ 4 fois sa taille de fichier en mémoire en raison de l’encodage UTF-16 de JavaScript. La surcharge restante inclut l’arbre d’état de l’éditeur, la chaîne de sortie formatée et la représentation interne des objets de V8 de la structure analysée. V8 alloue de la mémoire pour les Hidden Classes, les magasins de sauvegarde des propriétés et les magasins de sauvegarde des éléments pour chaque objet dans le tableau. Chacun des 180 000 objets porte une surcharge structurelle fixe au-delà des octets de clé et de valeur.
Fusion de Bascules : Référence de Synchronisation
L’approche naïve basée sur le temps (un debounce de 50 ms en queue) échoue car elle ne fait que retarder —elle n’annule pas. Chaque bascule met en file d’attente une requête worker distincte, et lorsque le worker est occupé pendant 2+ secondes par cycle, la file d’attente croît sans limite.
L’approche correcte est une référence de synchronisation :
// Définie de manière synchrone dans le gestionnaire de clic lorsque le worker est occupé
pendingToggleRef.current = true;
// Vérifiée dans le callback onmessage du worker — n'envoie que l'état final
if (pendingToggleRef.current) {
pendingToggleRef.current = false;
worker.postMessage({ ...latestState });
}
Aucune file d’attente n’est créée. Le worker reçoit au plus une requête par cycle, transportant l’état final de bascule de l’utilisateur.
Spam Contrôlé : 2 Minutes d’Abus Soutenu
Maintenir la touche Entrée sur la bascule Sort Keys pendant 2 minutes d’affilée a produit 22 cycles propres :
| Métrique | Valeur |
|---|---|
| Réponses totales du worker | 22 |
| Durée | ~120 s |
| Temps de cycle moyen | ~5,5 s |
| JS Heap maximal | 270 Mo |
| JS Heap (après GC inactif) | 183 Mo |
Chaque cycle a fusionné des centaines de frappes en une seule requête. L’onglet n’a jamais gelé et n’a jamais dépassé 270 Mo de JS heap.
Les 22 réponses ont montré parseTimeMs=0, confirmant que chaque requête après la première a trouvé l’objet mis en cache du worker. Le temps de cycle de 2 à 4 secondes était dominé par le clonage profond et le tri, pas par l’analyse JSON.
Abus Extrême : Clics de Bascule Ininterrompus
Un scénario plus agressif —basculer entre plusieurs boutons de bascule (sort keys, minify, indent) aussi rapidement que possible pendant 2,5 minutes— pousse l’architecture dans un mode de défaillance différent. La référence de fusion empêche l’accumulation de la file d’attente, mais n’impose pas d’intervalle de repos. Tant que l’utilisateur continue de cliquer, chaque cycle terminé déclenche immédiatement le suivant, maintenant le worker à 100 % de cycle de service sans fenêtre d’inactivité pour le ramasse-miettes.
Cela a produit plus de 50 cycles consécutifs avec la mémoire augmentant régulièrement :
| Métrique | Valeur |
|---|---|
| Réponses totales du worker | 50+ |
| Durée | ~150 s |
| JS Heap maximal | 3,2 Go |
Même à la mémoire maximale, tous les contrôles d’UI restaient visuellement réactifs. Les menus déroulants s’ouvraient et se fermaient. Les interrupteurs de bascule s’animaient entre les états cochés et décochés. L’utilisateur pouvait interagir avec n’importe quel bouton ou menu.
Cependant, deux choses ont cassé :
Barre d’état bloquée sur « Processing ». Après que l’utilisateur a cessé de cliquer et que la mémoire s’est stabilisée à 1,0-1,1 Go, la barre d’état continuait d’afficher « Worker: Processing… » sans récupération. Sous la pression de 3,2 Go de heap, le planificateur de React a abandonné la mise à jour d’état enveloppée dans startTransition de la dernière réponse du worker, laissant isProcessing à true en permanence. Le temporisateur de refroidissement de 300 ms a également probablement été abandonné.
Le panneau de sortie a cessé de se mettre à jour. Le JSON formaté dans l’éditeur de sortie n’a jamais reflété l’état final de bascule. Le worker avait terminé et envoyé sa réponse (visible dans les logs de la console), mais setOutputCode() était enveloppé dans startTransition avec setIsProcessing(false), et les deux ont été abandonnés.
Il s’agit d’un cas limite délibéré, pas d’un bug. Si un utilisateur maintient Entrée sur une bascule pendant 2,5 minutes d’affilée et pousse son navigateur à 3,2 Go de heap, l’application fonctionne en dehors de ses paramètres de conception. Toute contre-mesure d’ingénierie (temporisateur de surveillance, refroidissement forcé) pénaliserait les 99,9 % d’interactions normales simplement pour se défendre contre un scénario qu’un rechargement de page résout en 2 secondes. On ne peut pas concevoir une application web pour survivre à un abus malveillant sans dégrader l’expérience pour tous les autres.
La différence entre 270 Mo et 3,2 Go n’est pas un échec de fusion. Les deux scénarios fusionnent les clics en requêtes uniques par cycle. Le scénario de 3,2 Go exécute simplement 50+ cycles dos à dos sans marge de respiration, ne laissant aucune fenêtre pour le GC ou le planificateur de React. Cela dépasse les paramètres de conception de l’application.
GC Inactif et Récupération d’Échec de Cache
Le worker emploie un temporisateur d’inactivité de 60 secondes qui libère son objet interne en cache pour éviter la rétention mémoire à long terme. Après 60 secondes d’inactivité, cliquer sur une bascule déclenche un échec de cache et le thread principal renvoie la chaîne d’entrée complète :
| Phase | Temps |
|---|---|
| Détection d’échec de cache | ~0 ms |
| Renvoi du code + analyse à froid | 1 486 ms |
| Stringify | 108 ms |
| Récupération totale | 3 795 ms |
Après la fin de la récupération à froid, le heap s’est stabilisé à 183 Mo, moins que les 232 Mo initiaux, indiquant que le ramasse-miettes avait récupéré les allocations intermédiaires du spam de bascules précédent.
Profil Mémoire Complet
| Instantané | Condition | JS Heap |
|---|---|---|
| 1 | Après formatage initial | 232 Mo |
| 2 | Après 2 min de spam de bascules | 270 Mo |
| 3 | Après 60 s de GC inactif + récupération | 183 Mo |
| 4 | Après effacement | 34 Mo |
L’application se rétablit à une mémoire quasi basale après avoir effacé l’entrée, confirmant l’absence de fuites dans le cycle de vie du worker ou la gestion d’état de l’éditeur.
Points Clés à Retenir
-
La délégation aux Web Workers élimine les blocages d’UI : même à 102 Mo, le thread principal reste réactif pendant tout le traitement.
-
La fusion de bascules empêche l’accumulation de file d’attente :
pendingToggleReffusionne N clics en 1 requête. Un debounce basé sur le temps ne peut pas garantir cela car les délais n’annulent pas. -
La mémoire maximale dépend de la durée de l’abus : sous spam soutenu de 2 minutes avec pauses naturelles, le heap atteint un maximum de 270 Mo. Sous clics ininterrompus sans intervalles de repos (50+ cycles consécutifs), le heap peut atteindre 3,2 Go. Un temporisateur de refroidissement entre les cycles éliminerait cet écart.
-
La récupération d’échec de cache est progressive : le worker signale l’invalidation du cache, le thread principal renvoie l’entrée et le traitement se termine sans perte de données.
Le modèle d’ingénierie qui permet cela est simple en principe mais exige de la discipline dans son application : ne bloquer le thread principal avec rien, fusionner chaque variation d’entrée exactement en un état en attente, et donner à chaque allocateur un chemin de retour vers zéro.