FAQ performance et sécurité : lent, saccadé et données non fiables
L’essentiel : Pourquoi un fichier JSON de dizaines de Mo ne s'ouvre pas, combien coûte un clonage profond, comment empêcher la pollution par __proto__, et où le XSS entre — dix conclusions de premiers secours classées par symptôme.

L'essentiel : diagnostiquez d'abord par symptôme — parsing, rendu ou mémoire — puis suivez l'ordre de correction : réduire le volume de données, arrêter le clonage profond, déplacer le parsing dans un Worker avec défilement virtuel, et diffuser tout ce qui dépasse 50 Mo. Pour les entrées non fiables, limitez taille et profondeur d'imbrication avant de filtrer __proto__, constructor et prototype.
« JSON est lent » est inexact neuf fois sur dix ; ce qui est réellement lent est généralement le rendu, le clonage profond ou le mauvais ordre des opérations.
1. Pourquoi c'est lent
Le parsing d'un grand fichier JSON est lent. Comment l'optimiser ?
Localisez d'abord le goulot, parlez d'optimisation ensuite. Les étapes diffèrent de plusieurs ordres de grandeur :
| Étape | Temps approximatif pour 10 Mo de données | Pourquoi c'est le goulot |
|---|---|---|
| Transfert réseau | Dépend de la bande passante | Le JSON non compressé se compresse généralement à environ 1/5 |
JSON.parse |
20 à 80 ms | Une implémentation native, déjà rapide |
| Parcours récursif / clonage profond | 200 à 2000 ms | Traitement nœud par nœud en pur JS |
| Rendu DOM (vue arborescente) | Des secondes, fige facilement | Le nombre de nœuds explose |
L'ordre d'optimisation devrait donc être :
- Réduire le volume de données : faites paginer l'API, retournez seulement les champs nécessaires, et activez gzip/brotli ;
- Éviter le clonage profond : voir la section suivante ; c'est le coût caché le plus courant ;
- Rendu paresseux : dans la vue arborescente ne rendez que les niveaux développés au lieu de générer des dizaines de milliers de nœuds DOM d'un coup ;
- Flux : ne parsez pas tout d'un coup quand les données dépassent 10 Mo.
Si le goulot est réellement JSON.parse, cela signifie que les données font déjà des dizaines de Mo ou plus, et le bon geste est un parseur en flux :
import { parser } from 'stream-json';
import { streamArray } from 'stream-json/streamers/StreamArray';
// Handle array elements one by one; memory usage is independent of the total size
const pipeline = fs.createReadStream('big.json')
.pipe(parser())
.pipe(streamArray());
pipeline.on('data', ({ value }) => {
processItem(value);
});
Un JSON de dizaines de Mo plante le navigateur dès l'ouverture. Que faire ?
Confirmez d'abord s'il « plante » ou « fige ». Les deux exigent un traitement complètement différent :
- Fige : l'onglet affiche « Page non réactive » et peut se rétablir après des dizaines de secondes. La cause est des tâches longues saturant le thread principal ;
- Plante : l'onglet devient « Aw, Snap », généralement un épuisement de mémoire (OOM).
Dans les deux cas, ne le traitez pas sur le thread principal. Déplacez le parsing dans un Web Worker :
// worker.js
self.onmessage = (event) => {
const parsed = JSON.parse(event.data);
self.postMessage(parsed);
};
Un Worker résout « l'interface ne fige pas », pas le plafond de mémoire. Un onglet de navigateur a généralement 1 à 4 Go disponibles ; un JSON de 200 Mo parsé en objets peut occuper 1 Go ou plus (chaque paire clé-valeur porte des surcoûts d'objet), et avec le rendu par-dessus, il atteint facilement la limite.
La vraie solution est de ne jamais tout charger :
| Taille des données | Approche |
|---|---|
| < 5 Mo | Parser directement avec JSON.parse, surveiller le rendu |
| 5 à 50 Mo | Parsing dans un Worker + défilement virtuel |
| > 50 Mo | Parsing en flux, garder seulement les champs nécessaires |
| > 500 Mo | Changer le format (Parquet / SQLite / JSON Lines fragmenté) |
JSON.parse(JSON.stringify(obj)) est-il un clonage profond ?
C'est un clonage profond, mais il « change silencieusement les données » à plusieurs endroits, donc il ne convient pas comme outil de clonage général.
const source = {
date: new Date(),
fn: () => {},
undef: undefined,
nan: NaN,
map: new Map(),
nested: { a: 1 },
};
JSON.parse(JSON.stringify(source));
// {
// date: "2026-10-30T03:00:00.000Z", // Date becomes a string
// nan: null, // NaN becomes null
// nested: { a: 1 } // only this level is correct
// }
// fn and undef disappear, map becomes {}
La liste des problèmes :
| Type | Résultat |
|---|---|
Date |
Devient une chaîne ISO |
NaN / Infinity |
Devient null |
undefined / fonctions / Symbol |
Disparaît dans les objets, devient null dans les tableaux |
Map / Set |
Devient {} |
BigInt |
Lève un TypeError |
| Références circulaires | Lève un TypeError |
Les navigateurs modernes ont structuredClone, qui prend en charge Date, Map, Set et les références circulaires mais pas les fonctions :
const copy = structuredClone(source);
S'il ne s'agit que d'une fusion de configuration superficielle, l'opérateur spread est l'option la moins coûteuse : { ...config, timeout: 3000 }.
Pourquoi l'expansion de la vue arborescente est-elle si saccadée ?
Parce que chaque nœud est un élément DOM, et une fois le nombre de nœuds à des dizaines de milliers le coût de layout du navigateur explose.
Un JSON de 10 Mo peut contenir 50 000 à 100 000 nœuds feuilles. Même si vous ne rendez que la zone visible, construire le DOM une fois prend des centaines de millisecondes à des secondes, et chaque défilement ultérieur déclenche un reflow.
La bonne approche :
- Défilement virtuel : ne rendez que les quelques dizaines de lignes dans la fenêtre et réutilisez les nœuds DOM au défilement ;
- Replier par défaut : développez seulement les deux premiers niveaux et rendez les niveaux plus profonds à la demande ;
- Limiter l'échelle de rendu : au-delà de 10 000 nœuds, affichez « Données trop volumineuses, utilisez la recherche pour les localiser » au lieu de forcer un rendu.
C'est aussi pourquoi l'éditeur de ce site se dégrade gracieusement pour les grands fichiers — non pas parce qu'il ne peut techniquement pas les rendre, mais parce que les rendre serait inutilisable.
2. Pourquoi ça se fait attaquer
Comment fonctionne l'injection __proto__ ?
La pollution de prototype. Un attaquant construit du JSON avec une clé __proto__ ; si votre code le fusionne dans un objet sans filtrage,
il écrase Object.prototype et affecte chaque objet du runtime.
const payload = JSON.parse('{"__proto__": {"isAdmin": true}}');
// Dangerous code: recursive merge without checking key names
function merge(target, source) {
for (const key in source) {
if (typeof source[key] === 'object') {
target[key] = target[key] || {};
merge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
return target;
}
merge({}, payload);
({}).isAdmin; // true — every object has been polluted
Le fait clé : JSON.parse lui-même ne pollue pas le prototype ; {"__proto__": ...} se parse en une propriété propre ordinaire.
Ce qui est dangereux, c'est la fusion, l'affectation, Object.assign, lodash.merge et opérations similaires après le parsing.
Trois défenses :
- Filtrer les noms de clés : sauter
__proto__,constructoretprototypelors de la fusion ; - Utiliser
Object.create(null)comme conteneur pour qu'il n'y ait pas de chaîne de prototypes à polluer ; - Utiliser une
Mappour stocker des paires clé-valeur non fiables.
function safeMerge(target, source) {
for (const key of Object.keys(source)) {
if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
continue;
}
const value = source[key];
if (value && typeof value === 'object' && !Array.isArray(value)) {
target[key] = safeMerge(target[key] ?? {}, value);
} else {
target[key] = value;
}
}
return target;
}
Notez aussi que for...in parcourt les propriétés héritées ; Object.keys() est plus sûr.
À quoi faut-il faire attention en parsant du JSON non fiable ?
Procédez dans cet ordre — coût faible, bénéfice élevé :
- Limiter la taille : rejeter immédiatement tout ce qui dépasse le seuil, sans le charger d'abord en mémoire ;
- Limiter la profondeur : un JSON imbriqué sur des centaines de niveaux peut faire déborder la pile d'un parseur récursif ;
- Limiter le nombre de clés : prévient les attaques par collision de hachage et l'amplification mémoire ;
- Filtrer les clés dangereuses :
__proto__,constructor,prototype; - Valider la structure : la passer dans un Schema pour confirmer types et champs obligatoires ;
- Ne pas faire confiance au
Content-Type: le type déclaré et le contenu réel peuvent différer.
Une vérification de profondeur est peu coûteuse ; vous pouvez utiliser un filtre regex grossier avant le parsing, ou parcourir après le parsing :
function measureDepth(value, depth = 0) {
if (value === null || typeof value !== 'object') {
return depth;
}
let maxDepth = depth;
for (const key of Object.keys(value)) {
maxDepth = Math.max(maxDepth, measureDepth(value[key], depth + 1));
}
return maxDepth;
}
Mais ce code est lui-même récursif et peut encore déborder la pile sur des données malicieusement profondes. L'approche sûre est d'imposer la limite pendant le parsing,
avec un parseur qui prend en charge une option de profondeur (certaines bibliothèques fournissent un paramètre maxDepth).
Comment un XSS via JSON arrive-t-il ?
JSON lui-même n'est pas du HTML et ne s'exécute pas. Le XSS se produit au point où « une valeur JSON est concaténée dans du HTML ».
Trois chemins courants :
| Chemin | Exemple | Défense |
|---|---|---|
| Concaténation de template côté serveur | res.send('<div>' + data.name + '</div>') |
Échapper le HTML à la sortie |
innerHTML frontend |
el.innerHTML = data.html |
Utiliser textContent ou assainir |
| Intégration de script en ligne | <script>var d = ${JSON.stringify(data)}</script> |
Échapper <, >, & |
Le troisième est le plus subtil. JSON.stringify n'échappe pas <, donc des données contenant </script><script>alert(1)</script>
peuvent fermer la balise script trop tôt :
const data = { name: '</script><script>alert(1)</script>' };
// Output: <script>var d = {"name":"</script><script>alert(1)</script>"}</script>
La correction est de convertir les caractères dangereux en échappements Unicode :
function safeJsonForHtml(data) {
return JSON.stringify(data)
.replace(/</g, '\\u003c')
.replace(/>/g, '\\u003e')
.replace(/&/g, '\\u0026')
.replace(/\u2028/g, '\\u2028')
.replace(/\u2029/g, '\\u2029');
}
Faut-il limiter la profondeur d'imbrication ?
Oui. C'est l'une des défenses au meilleur rapport coût-bénéfice.
Les conséquences de ne pas la limiter : les fonctions récursives débordent la pile, JSON.parse plante sur certaines implémentations quand la profondeur est trop grande,
et la sérialisation fait déborder la pile de la même manière. Un [[[[...]]]] de 1 Ko suffit à faire tomber un parseur non protégé.
Un seuil raisonnable dépend du métier :
| Type de données | Limite suggérée |
|---|---|
| Objets métier ordinaires | 10 à 20 niveaux |
| Structures arborescentes (organigrammes, répertoires) | 50 à 100 niveaux |
| Passerelle d'API générale | 20 niveaux |
Qu'est-ce que le détournement JSON ?
Une vulnérabilité historique : les anciens navigateurs permettaient à un <script src> inter-origine de charger et exécuter du JSON, et les attaquants volaient des données en redéfinissant
le constructeur Array ou Object.prototype.__defineSetter__.
Les protections modernes sont assez complètes :
- Les navigateurs n'autorisent plus le détournement de littéraux de tableau avec
__defineSetter__; - Un
Content-Type: application/jsoncorrect empêche l'exécution de script ; X-Content-Type-Options: nosniffempêche le MIME sniffing.
Les systèmes anciens portent encore un risque résiduel. Conseil pratique : utilisez un objet au niveau supérieur, pas un tableau nu.
Un enveloppant comme {"data": [...]} empêche la réponse d'être une instruction JavaScript valide ({ est traité comme un bloc de code),
bloquant le détournement au niveau syntaxique.
Quels sont les risques de journaliser du JSON ?
Trois :
- Informations sensibles écrites sur disque : numéros de téléphone, numéros d'identité, tokens et mots de passe en clair atterrissent dans le système de journalisation, qui a généralement des permissions plus laxistes et une rétention plus longue ;
- Injection de journal : une valeur JSON contenant des retours à la ligne et des préfixes de journal forgés divise un enregistrement en plusieurs et pollue la piste d'audit ;
- Explosion de taille : écrire une réponse entière de 10 Mo dans le journal remplit vite le système de journalisation.
Approche :
// Redact and truncate before logging
function safeLog(data) {
const cloned = structuredClone(data);
for (const key of ['password', 'token', 'idCard', 'phone']) {
if (key in cloned) cloned[key] = '***';
}
const text = JSON.stringify(cloned);
return text.length > 2000 ? text.slice(0, 2000) + '...(truncated)' : text;
}
Les systèmes de journalisation structurée (comme pino) échappent eux-mêmes les retours à la ligne, mais la rédaction et la troncature restent votre travail.
3. Aide-mémoire
| Symptôme | Vérifier d'abord | Correction |
|---|---|---|
| Parsing lent | Volume de données, clonage profond en jeu | Découpage/flux, supprimer JSON.parse(JSON.stringify()) |
| La page fige | Tâches longues sur le thread principal, nombre de nœuds rendus | Worker + défilement virtuel |
| L'onglet plante | Usage mémoire | Ne pas tout charger ; passer au flux |
| Le clonage profond perd des données | Date, NaN, Map |
Utiliser structuredClone |
| Les objets ont soudain des propriétés en trop | Pollution de prototype | Filtrer __proto__, utiliser une Map |
| Un script surgit | Chaînes concaténées dans du HTML | Échapper <, utiliser textContent |
| Débordement de pile | Profondeur d'imbrication | Limiter à 10 à 20 niveaux |
| Les journaux se remplissent | La réponse entière est journalisée | Tronquer + rédiger |