Aller au contenu
jsonbeautifiers
Français

Ce qui casse vraiment quand les fichiers JSON grossissent

Chaque seuil de taille dans l’outillage JSON vient d’une limite précise, et celle dont on vous avertit est en général la mauvaise.

Chaque affirmation de cette page est soit mesurée, soit sourcée. Quand elle n’est ni l’une ni l’autre, la page le dit.

L’export arrive sous la forme d’un unique fichier .json de 40 Mo. La coloration syntaxique de l’éditeur abandonne, et le coller dans un formateur web laisse l’onglet blanc plusieurs secondes. Quelqu’un suggère le streaming, un autre affirme que la récursion fera exploser la pile, et ces deux conseils visent des problèmes que vous n’avez pas encore.

La question utile n’est pas « ce fichier est-il gros » mais « quelle limite vais-je heurter ». Il n’y en a qu’environ quatre, et elles arrivent dans un ordre fixe.

L’échelle des tailles

Taille Ce qui se passe
1 Mo Rien. L’analyse native prend quelques millisecondes, l’arbre d’objets fait des dizaines de Mo. Tous les outils fonctionnent, y compris ceux qui sont mal écrits.
10 Mo Le JSON.parse natif prend environ 158 ms. Un tokeniseur écrit en JavaScript, comme celui de ce site, met environ 780 ms à formater la même entrée. Construire pour elle un arbre de nœuds navigable coûte environ 294 Mo de tas. Tout fonctionne encore, mais une analyse synchrone dure désormais assez longtemps pour ressembler à un plantage.
100 Mo Extrapolez les mêmes chiffres : plusieurs secondes d’analyse dans un tokeniseur JS, et un arbre qui se compte en gigaoctets. C’est là que les onglets commencent à mourir de mémoire plutôt que de temps. Côté serveur, cela reste de la routine.
500 Mo et plus V8 plafonne une chaîne unique à 536 870 888 caractères, environ 512 Mo d’ASCII. Dans Chrome et Node, le fichier ne peut pas être lu dans une chaîne du tout, donc aucun outil bâti sur cette étape ne peut y toucher, quelle que soit sa qualité.

Les trois premières lignes sont mesurées sur l’outillage de ce site ; votre analyseur donnera d’autres chiffres. La quatrième n’est pas une mesure de performance, c’est un plafond dur dans le moteur.

Le mur des 512 Mo

Tout outil JSON de navigateur suit le même chemin : lire le fichier dans une chaîne, passer la chaîne à un analyseur. C’est à cette première étape qu’un très gros fichier meurt. La longueur maximale de chaîne du moteur est une constante fixe, et FileReader.readAsText ou Response.text() sur quoi que ce soit au-delà lève une erreur avant que votre code ne s’exécute.

La constante dépend du moteur. V8 s’arrête à 536 870 888 caractères (require('buffer').constants.MAX_STRING_LENGTH sur Node 64 bits), SpiderMonkey à 1 073 741 822, et JavaScriptCore à 2 147 483 647. Firefox et Safari survivent donc à des fichiers que Chrome refuse, mais les trois ont un plafond, et un outil qui doit fonctionner partout se conçoit contre celui de V8.

Un analyseur par blocs sur un ReadableStream va un peu plus loin, car il ne matérialise jamais le document en une seule chaîne. Cela règle le plafond de chaîne mais pas le problème suivant : le résultat analysé doit lui aussi tenir en mémoire. Tout ce qui dépasse quelques centaines de Mo n’est pas un problème de navigateur : déplacez-le vers un shell, un runtime de langage ou une base de données.

Pourquoi l’arbre analysé est tellement plus gros que le fichier

Que 10 Mo de texte deviennent environ 294 Mo de tas surprend, mais l’arithmétique n’a rien de mystérieux. Prenez {"id":1,"ok":true}, soit 18 octets sur le disque. En mémoire, c’est :

  • Un objet avec un en-tête et un pointeur vers sa forme ou sa carte de propriétés.
  • Un emplacement de la largeur d’un pointeur par propriété avant les valeurs, quatre octets sur une compilation 64 bits avec compression de pointeurs et huit sans.
  • Chaque valeur de chaîne portant son propre en-tête, son champ de longueur et ses données de caractères, et chaque clé de chaîne faisant de même sauf si le moteur l’a internée.
  • Les valeurs qui ne sont pas de petits entiers stockées comme cellules de tas distinctes avec leurs propres en-têtes, atteintes via un autre pointeur.

Le surcoût est par nœud, pas par octet, donc le rapport empire à mesure que les données se structurent : 10 Mo d’une seule longue chaîne coûtent peu, 10 Mo de quatre-vingt mille petits objets à huit clés chacun, non. Le résultat d’un simple JSON.parse est plus léger qu’un arbre de visualiseur portant des métadonnées par nœud, mais il reste un multiple de la source. Prévoyez un ordre de grandeur, puis mesurez votre propre forme.

Le mythe de la récursion, corrigé

L’avertissement standard veut qu’un JSON profondément imbriqué fasse exploser la pile à l’analyse. Dans un navigateur, sur un moteur actuel, ce n’est plus vrai. V8 a remplacé l’analyseur JSON récursif par un analyseur itératif en v7.6, et il lit un million de niveaux d’imbrication sans broncher. Mesuré sur Node v24.15.0 avec V8 13.6.233.17, l’analyse se passait bien à des profondeurs autrefois fatales.

L’échec s’est déplacé de l’autre côté. JSON.stringify reste récursif et lève un RangeError quelques milliers de niveaux plus bas ; sur la même compilation, autour de 4 800 :

const deepText = '{"a":'.repeat(1000000) + '1' + '}'.repeat(1000000);
const deep = JSON.parse(deepText); // parfait, un million de niveaux

JSON.stringify(deep);              // RangeError: Maximum call stack size exceeded

Ce nombre n’est pas une constante. Il varie avec la taille de pile avec laquelle le runtime a démarré et avec tout ce qui se trouve sur la pile au moment de l’appel : ce n’est donc pas un chiffre contre lequel concevoir.

Un service peut donc accepter un payload hostile, l’analyser sans se plaindre, le stocker, puis s’effondrer quand il essaie de le journaliser ou de le réémettre. Les limites de profondeur restent à leur place à la frontière d’entrée, même si c’est précisément l’entrée qui survit.

D’autres runtimes sont moins indulgents dans les deux sens. Le module json de CPython récurse au décodage comme à l’encodage, donc une entrée profondément imbriquée lève une RecursionError dès l’arrivée. La profondeur atteinte avant cela dépend de la compilation : le scanner C de CPython 3.14 a lâché ici vers 14 000 niveaux, bien en deçà de ce qu’accepte V8. Si vous traversez des frontières de langage, la profondeur que tolère votre service est celle de son maillon le plus strict.

Le streaming, et les parties que l’on rate

Streamer veut dire ne jamais tenir le document entier. Tous les langages courants disposent d’un analyseur en mode pull pour cela.

ijson, en Python, produit les valeurs correspondant à un chemin préfixe. Le préfixe records.item signifie « chaque élément du tableau situé sous la clé racine records », et item est le jeton littéral pour un élément de tableau, pas un joker pour un nom de champ. C’est le détail que l’on rate à la première utilisation :

import ijson

total = 0
with open("events.json", "rb") as f:            # mode binaire, pas texte
    for record in ijson.items(f, "records.item"):
        if record["status"] == "failed":
            total += 1

print(total)

ijson choisit le backend le plus rapide disponible au moment de l’import, et un backend C est bien plus rapide que le repli en Python pur. Vérifiez lequel vous avez avant de conclure que le streaming est lent.

Le encoding/json de Go fait la même chose avec Decoder, et le piège y est différent. Appeler Decode une seule fois sur un tableau racine décode tout le tableau dans un seul slice, ce que vous cherchiez précisément à éviter. Il faut d’abord consommer le crochet ouvrant comme jeton, puis décoder élément par élément :

f, err := os.Open("events.json")
if err != nil { log.Fatal(err) }
defer f.Close()

dec := json.NewDecoder(f)
if _, err := dec.Token(); err != nil { log.Fatal(err) } // lit le '['

for dec.More() {
    var r Record
    if err := dec.Decode(&r); err != nil { log.Fatal(err) }
    process(r)
}

Dans Node, stream-json (avec Pick pour sélectionner un sous-arbre et StreamArray pour émettre les éléments) ou l’ancien JSONStream font l’équivalent, et le JsonParser de Jackson vous donne la même boucle de jetons sur la JVM. Tous achètent un profil mémoire constant en renonçant à tout ce qui exige le document entier d’un coup.

Le format était le problème

Streamer un tableau JSON géant, c’est du travail que vous faites parce que le fichier n’aurait jamais dû être un seul tableau. NDJSON, une valeur JSON complète par ligne, supprime toute la catégorie de problèmes : vous lisez une ligne, vous l’analysez, vous la lâchez, et la mémoire est bornée par votre plus gros enregistrement isolé. Ça se découpe avec split, ça se grep comme du texte, ça s’ajoute sans réécriture, et ça survit à une écriture tronquée en perdant un enregistrement plutôt que le fichier.

Si vous êtes coincé avec un tableau aujourd’hui et voulez des lignes demain, NDJSON vers JSON convertit dans les deux sens, et le visualiseur JSON ouvre l’un comme l’autre.

Pourquoi 780 ms font un onglet cassé

Une analyse synchrone retient le thread principal. Rien ne se peint et aucun clic n’est enregistré. Au-delà d’environ 100 ms, une interaction cesse de sembler instantanée, et au-delà d’une seconde la page se lit comme figée et l’utilisateur tend la main vers le rechargement. Recharger relance l’analyse.

Le remède n’est pas un analyseur plus rapide, c’est de sortir le travail du thread qui affiche. Le beautifier de ce site analyse et formate dans un Web Worker, l’onglet continue donc de peindre et l’état d’avancement est réel plutôt qu’un mensonge affiché juste avant un appel bloquant. Ce choix structurel compte davantage que toute micro-optimisation du tokeniseur.

Quand la réponse n’est pas un outil

Certaines choses méritent d’être faites avant de recourir à tout ce qui précède.

Filtrez d’abord avec jq, pour que ce que vous chargez soit petit :

jq -c '.records[] | select(.status == "failed")' events.json > failed.ndjson

Notez que jq tout court lit le document entier en mémoire. Pour des fichiers plus gros que la RAM, jq --stream est le mode qui ne le fait pas, au prix d’une syntaxe à base d’événements nettement plus étrange.

Pour une tranche que vous voulez seulement regarder, l’outil de filtre fait la même sélection dans le navigateur, et le minifieur retire l’espace de mise en forme, ce qui représente sur un export indenté une vraie fraction des octets.

Et parfois la réponse honnête est que ce n’est pas un problème de fichier. Si vous grepez un export de 2 Go à répétition, l’écrire une fois dans SQLite ou DuckDB et l’interroger là rend toutes les questions suivantes bon marché. Un fichier que vous ne cessez de réanalyser vous a déjà dit qu’il voulait être une table.