Arrêtez de compresser JSON dans le mauvais ordre : minify vs gzip vs brotli, mesurés
L’essentiel : Minifier réduit de 30 à 50 % le JSON brut, mais gzip efface la plupart de ce gain. Chiffres mesurés sur de vraies formes, plus les quatre cas où minifier empire les choses.

L'essentiel : séparez d'abord la compression de transport de la réduction structurelle. Si gzip ou brotli n'est pas encore actif, corrigez la configuration serveur — gzip -6 seul atteint 94,5 % et ne laisse à minify que 0,9 point. Les 46,3 % complètes de minify n'arrivent que lorsque la facture d'octets est enregistrée localement : corps de requête, les 6 Mo de Lambda, les 1 MiB de Firestore, ou du JSON écrit sur disque et dans les journaux.
Le badge « 35 % économisés » de votre minifier préféré est honnête — il mesure les octets bruts. Votre API tourne au-dessus de gzip ou brotli, et ces deux-là mangent exactement la même redondance. Multipliez les deux nombres et vous obtenez la mauvaise réponse.
1. Séparez la « compression » en deux choses différentes
Ce que les gens appellent compression JSON est en réalité deux opérations sans rapport, et les mélanger est le point de départ de toutes les conclusions fausses.
| minify (supprimer les espaces) | gzip / brotli (compression de transport) | |
|---|---|---|
| Nature | Une transformation du document | Une transformation du transport |
| Sortie | Toujours du texte JSON valide et lisible | Binaire ; inutilisable avant décodage |
| Où atterrit le gain | Disque, mémoire, journaux, limites de taille | Seulement sur le fil et dans les caches |
| Après décodage | Les octets restent plus petits | Les octets reviennent à la taille originale |
| Qui le possède | Votre script de build, vos arguments de sérialiseur | Nginx, CDN, framework serveur |
Les deux dernières lignes sont celles qui comptent. Les limites de plateforme sont mesurées après décodage : le plafond de 6 Mo de charge utile d'AWS Lambda, le plafond de 1 MiB par document de Firestore. Activer gzip ne vous achète pas un seul octet là-bas, car les deux comptent le résultat décodé.
2. minify supprime exactement une chose : les espaces structurels
La RFC 8259 autorise les espaces (space, \t, \n, \r) autour de tout caractère structurel. Rien d'autre dans un document JSON n'est facultatif : les noms de clés sont des données, les nombres gardent chaque chiffre, et le contenu des chaînes est intouchable octet par octet.
Cela réduit toute l'opération à une ligne :
const minified = JSON.stringify(JSON.parse(pretty));
// RFC 8259 allows whitespace only around structural characters,
// so spaces inside a string are data, not formatting
JSON.stringify(JSON.parse('{"note": "two spaces"}'));
// {"note":"two spaces"} - both inner spaces are preserved
Trois opérations méritent d'être signalées, qui sont classées sous « compression » mais modifient réellement le document : supprimer les clés dont la valeur est null, arrondir les flottants et réordonner les clés. Elles changent le document plutôt que de le reformater, donc tout outil qui annonce une « compression profonde » a besoin de l'accord de votre consommateur avant que vous l'activiez.
Parce que minify ne supprime que des espaces, son bénéfice dépend presque entièrement de la proportion d'indentation dans votre fichier. Attendez-vous à voir cela revenir sans cesse dans les tableaux ci-dessous.
3. La mesure : quatre documents, trois niveaux gzip, plus brotli
Environnement : Node v22.13.1, zlib.gzipSync aux niveaux 1 / 6 / 9, brotliCompressSync à sa qualité par défaut (11). Les quatre échantillons couvrent délibérément les formes que vous livrez réellement : 50 commandes imbriquées (structure profonde, clés répétées), une configuration à 6 niveaux (clés longues, valeurs courtes), un tableau de 200 lignes de journal (chaînes longues, presque aucune indentation), et le vrai media-manifest.json de ce site (forme mixte, chaînes riches en URL).
Étape un : octets bruts.
| Échantillon | pretty (2 espaces) | pretty (4 espaces) | après minify | gain de minify |
|---|---|---|---|---|
| 50 commandes imbriquées | 29 551 B | 40 761 B | 15 881 B | 46,3 % |
| Configuration à 6 niveaux | 25 450 B | 38 012 B | 11 679 B | 54,1 % |
| Tableau de 200 lignes de journal | 14 582 B | 14 982 B | 13 981 B | 4,1 % |
| media-manifest.json | 25 149 B | 28 114 B | 21 222 B | 15,6 % |
C'est de là que vient le badge « 35 % économisés », et il ne ment pas. Le problème apparaît à l'étape deux.
Étape deux : compressez chaque variante, puis demandez combien minify ajoute encore.
| Échantillon | par-dessus gzip -1 | par-dessus gzip -6 | par-dessus gzip -9 | par-dessus brotli |
|---|---|---|---|---|
| 50 commandes imbriquées | 21,0 % | 17,0 % | 8,9 % | 10,6 % |
| Configuration à 6 niveaux | 74,2 % | 59,1 % | 58,0 % | 56,4 % |
| Tableau de 200 lignes de journal | -0,7 % | -0,4 % | 0,6 % | -0,3 % |
| media-manifest.json | 1,5 % | 1,0 % | 1,0 % | 1,6 % |
Le même document de commandes imbriquées qui perd 46,3 % de ses octets bruts ne garde que 17,0 % de cela une fois gzip -6 en jeu. Le tableau de journal devient carrément négatif : minify-puis-compresser est légèrement plus gros que compresser l'original embelli. Le négatif n'est pas un bug, c'est exactement ce qu'il faut attendre, et la section suivante explique pourquoi.
Étape trois : réduction totale par rapport au document embelli original — le nombre qui voyage réellement sur le fil.
| Échantillon | gzip -6 seul | minify + gzip -6 | minify + brotli |
|---|---|---|---|
| 50 commandes imbriquées | 94,5 % | 95,4 % | 96,9 % |
| Configuration à 6 niveaux | 96,0 % | 98,4 % | 99,0 % |
| Tableau de 200 lignes de journal | 89,9 % | 89,9 % | 95,0 % |
| media-manifest.json | 83,5 % | 83,6 % | 86,1 % |
4. Pourquoi gzip mange le crédit de minify
gzip est du LZ77 suivi d'un codage Huffman. Le mouvement central de LZ77 est « regarde en arrière dans une fenêtre de 32 Ko, trouve une séquence répétée, remplace-la par un pointeur (distance, longueur) ». L'indentation est la cible la plus facile possible pour ce mouvement : chaque niveau d'imbrication émet une suite identique d'espaces, et des dizaines de lignes adjacentes répètent la même combinaison. Huffman assigne ensuite des codes courts aux symboles de haute fréquence, donc un motif comme \n plus huit espaces — qui peut revenir mille fois dans un même fichier — finit par coûter deux ou trois bits.
Donc les octets que minify supprime sont précisément les octets que le compresseur aurait réduits presque gratuitement. Le faire soi-même revient à refaire un travail que le compresseur maîtrise déjà, et vos 46 % s'effondrent à 17 %. C'est toute l'histoire derrière le tableau ci-dessus.
Le résultat négatif du tableau de journal mérite sa propre explication. Cet échantillon est presque entièrement composé de chaînes longues (lignes de journal, URL, base64), ce qui signifie peu de répétitions et une forte entropie — peu de redondance à supprimer au départ. Les retours à la ligne de la version embellie donnent par hasard au matcher des frontières stables. Supprimez-les et LZ77 perd ces ancres : dans cet échantillon, compresser l'original embelli en gzip -1 donne 1 508 octets, tandis que minify-puis-compresser donne 1 518 octets. Dix octets de plus.
Classés par magnitude, les leviers s'alignent ainsi :
| Levier | Gain par rapport au document embelli original (commandes imbriquées) | Coût |
|---|---|---|
| minify | 46,3 % (octets bruts uniquement) | Lisibilité perdue ; nécessite une étape de build |
| gzip -6 | 94,5 % | Un peu de CPU serveur ; configuration quasi nulle |
| minify + gzip -6 | 95,4 % | Les deux ci-dessus, additionnés |
| minify + brotli | 96,9 % | Nécessite HTTPS et le module brotli ; pré-compresser les actifs statiques |
Le saut de 0 à 94,5 % est le travail de gzip. Le saut de 94,5 % à 95,4 % — neuf dixièmes de point — est celui de minify. Ce tableau fixe où votre effort doit aller.
5. Le niveau de compression est la variable cachée
Tout argument qui suppose un niveau gzip fixe unique repose sur du sable, car le niveau change directement la valeur marginale de minify :
| Réglage de transport | Octets supplémentaires que minify économise encore (commandes imbriquées) |
|---|---|
gzip -1 (gzip_comp_level par défaut de Nginx) |
21,0 % |
| gzip -6 (défaut de la plupart des bibliothèques standard) | 17,0 % |
| gzip -9 | 8,9 % |
| brotli (qualité par défaut) | 10,6 % |
Plus votre compression de transport est faible, plus minify rapporte. Cela donne une règle pratique : sur une installation Nginx standard où gzip_comp_level vaut encore 1, minify vous donne 21 %. Une fois passé à 9 ou basculé sur brotli, cela tombe à 9-11 % et vous pouvez généralement l'ignorer.
Avant de débattre dans un sens ou dans l'autre, mesurez à quel palier vous êtes réellement :
# Fetch the same response with three Accept-Encoding values and compare what landed
for enc in identity gzip br; do
size=$(curl -s -o /dev/null -w '%{size_download}' \
-H "Accept-Encoding: $enc" https://api.example.com/v1/orders)
echo "$enc -> $size bytes"
done
Trois nombres répondent à trois questions à la fois : la compression est-elle seulement active, à quel niveau, et combien de place reste-t-il pour minify. Si identity et gzip reviennent presque identiques, la compression n'est pas active du tout — et la chose à corriger est Nginx, pas votre JSON :
gzip on;
gzip_comp_level 6;
gzip_min_length 256; # skip tiny responses, they only grow
gzip_types application/json application/javascript text/css;
6. Quand minify paie réellement
Un test décide : les octets que vous avez économisés seront-ils enregistrés quelque part, ou allez-vous heurter une limite qui compte les octets bruts ? Si oui, les 46 % complètes arrivent. Si la réponse est « ça n'existe que sur le fil », alors 17 % est sa vraie valeur.
Premier cas : les corps de requête HTTP. La plupart des serveurs ne décompressent pas les corps de requête, et gzip est configuré presque exclusivement pour les réponses. Le JSON que vous POST n'a généralement aucune protection de transport, donc les économies de minify arrivent au prix plein. Il en va de même pour les callbacks de webhook, la télémétrie MQTT et les trames WebSocket qui n'ont jamais négocié permessage-deflate.
Deuxième cas : les canaux à plafond d'octets dur — et chacun compte des octets décodés :
| Canal | Limite | gzip peut-il aider ? |
|---|---|---|
| Charge utile synchrone AWS Lambda | 6 Mo | Non, mesuré après décodage |
| Document unique Firestore | 1 MiB | Non |
| Message unique Kafka (par défaut) | 1 Mo | Non, la compression côté broker est une autre couche |
| Quota localStorage | ~5 Mo (compté en caractères) | Non |
| URL de requête GET | Dépend du serveur, 8 Ko est courant | Non |
Dans cette catégorie, minify n'est pas une optimisation, c'est la différence entre une requête qui passe et une qui échoue. Intégrer du JSON dans une chaîne de requête d'URL est le cas le plus tranchant : les URL ne sont jamais compressées et elles ont bien un plafond de longueur, donc minifiez d'abord, puis décidez si vous avez besoin de base64 du tout.
Troisième cas : le JSON écrit sur disque ou dans les journaux. Les journaux et les index stockent des octets littéraux sans couche de transport. Il y a une mise en garde qui fait trébucher : minify n'aide que si vous écriviez déjà du JSON embelli. Cet échantillon de 200 lignes de journal n'économisait que 4,1 %, car ses lignes étaient déjà compactes et l'indentation ne constituait presque rien du fichier. Inversement, si votre logger appelle JSON.stringify(obj, null, 2), supprimer le troisième argument est de l'argent gratuit.
Une dernière note : quand les octets bruts sont vraiment serrés, les noms de clés sont généralement un levier plus important que l'indentation. Sur media-manifest.json, remplacer ses 14 clés uniques par k0 à k13 fait passer la forme minifiée de 21 222 B à 18 887 B — encore 11,0 %. Compressé, cet avantage restant se réduit à 2,4 % sous gzip -6 et 1,3 % sous brotli. Même mécanisme que tout ce qui précède : l'indentation et les noms de clés sont tous deux des informations structurelles répétées, et le compresseur réclame le même dividende dans les deux cas.
7. Quatre cas où cela se retourne
Ne minifiez pas les fichiers que vous committez. Changez "qty": 2 en 3 et le fichier embelli montre une ligne propre dans git diff ; le fichier minifié réécrit toute la ligne, et avec des centaines de champs vous ne trouvez plus le changement du tout. C'est la même mécanique que dans Comment résoudre les conflits de fusion JSON — les outils de diff opèrent ligne par ligne, donc un JSON sur une seule ligne transforme chaque édition en « toute la ligne diffère ». Gardez les fichiers embellis dans le dépôt et ne minifiez que dans la sortie de build ou lors de l'écriture vers le stockage.
Ne compressez pas les petites réponses. Une réponse ne contenant que {"ok": true} fait 16 octets avec indentation, et 36 octets après gzip -6 — vingt octets plus gros, car le dictionnaire et les en-têtes propres à gzip occupent de l'espace. Ce n'est pas théorique ; c'est exactement pourquoi gzip_min_length existe. Son défaut Nginx de 20 octets est bien trop bas pour la production, où 256 est le plancher habituel.
N'attendez pas un parsing plus rapide. En passant le même ensemble de commandes à 4 000 enregistrements, le document embelli fait 2 374 378 octets contre 1 282 358 minifié — 46 % de moins — et pourtant le temps médian de JSON.parse ne passe que de 5,7 ms à 5,0 ms, environ 12 % plus rapide. Diviser la taille par deux ne divise pas le temps par deux, car le parseur dépense l'essentiel de son budget à découper des chaînes et convertir des nombres ; sauter des espaces est accessoire. Ce qui pilote réellement le coût de parsing, ce sont le nombre de clés et le contenu des chaînes, ce que Parser, rendre et diffuser de grands fichiers JSON détaille précisément.
Ne le rétro-adaptez pas à un service qui tourne déjà en brotli ou gzip -9. Le bénéfice tombe à 9-11 %, tandis que le coût — la perte permanente de lisibilité pendant le débogage — reste. Vérifiez plutôt si gzip_types couvre seulement application/json. Beaucoup de sites activent gzip et ne compressent jamais que text/html, laissant JSON totalement non compressé ; dans cette situation, minify n'est pas l'étape un, corriger votre configuration l'est.
8. Tableau de décision : quelle étape d'abord
| Votre situation | Faites ceci d'abord | Où minify se classe |
|---|---|---|
| Aucune compression de transport | Configurez gzip_types de Nginx ou activez l'auto-compression CDN |
Sautez-le ; neuf dixièmes du gain s'évaporent |
| gzip -1 en cours (défaut Nginx) | Montez gzip_comp_level à 6 |
Deuxième, vaut 21 % |
| Déjà en brotli ou gzip -9 | Auditez les clés longues, ajoutez de la pagination ou rognez des champs | Sautez-le ; il ne reste que 9-11 % |
| Vous heurtez les 6 Mo de Lambda ou les 1 MiB de Firestore | Minifiez directement ; les 46 % complètes comptent | Premier |
| Corps POST, webhooks, MQTT | Minifiez directement ; le transport s'applique rarement ici | Premier |
| Fichiers de configuration allant dans Git | Gardez-les embellis | Seulement en sortie de build |
| Journaux, télémétrie, exports hors ligne | Supprimez l'argument null, 2 de votre logger |
C'est l'étape un |
La règle en une phrase : regardez quelle facture d'octets vous essayez de réduire — une facture enregistrée localement (disque, quota, limite de plateforme, corps de requête) ou une facture qui n'existe que sur le fil. Local d'abord signifie minify ; fil uniquement signifie activer brotli ou gzip d'abord.
En pratique, exécutez la boucle curl ci-dessus pour confirmer que la compression de transport est active, puis passez un échantillon dans l'outil JSON Minify et lisez son chiffre d'économies. Si ce nombre dépasse 40 % alors que curl rapporte identity et gzip à une taille presque identique, votre goulot est la configuration serveur, pas le JSON. Si les deux nombres curl sont déjà proches, ce document a peu à vous offrir et vous devriez plutôt rogner des champs ou paginer — le sujet de Les essentiels de la conception de contrat d'API JSON.
Le JSON minifié est-il toujours du JSON valide ?
Oui. minify ne supprime que les espaces que la RFC 8259 autorise autour des caractères structurels et ne touche jamais le contenu des chaînes, donc JSON.parse produit une valeur identique à l'originale. La seule victime est la lisibilité humaine.
brotli ou gzip ?
Sur le même échantillon minifié, brotli économise 96,9 % et gzip -6 95,4 % — un écart étroit. Utilisez gzip -6 pour les réponses dynamiques afin de garder le CPU bas, et réservez brotli aux actifs statiques livrés en HTTPS que vous pouvez pré-compresser en fichiers .br.
J'ai minifié mon JSON mais l'API n'est pas devenue plus rapide. Pourquoi ?
Vérifiez la réponse avec curl -sI pour un en-tête Content-Encoding: gzip. Beaucoup de configurations Nginx laissent gzip_types à text/html seul, donc JSON n'était jamais couvert — et moins d'un cinquième de vos 46 % n'a jamais atteint le fil.
Les fichiers de configuration doivent-ils être committés minifiés ?
Non. Les outils de diff comparent ligne par ligne, donc un JSON sur une seule ligne transforme chaque édition en « toute la ligne a changé », et résoudre les conflits coûte bien plus que les octets économisés. Gardez les versions embellies dans le dépôt et minifiez au build.
Quelle taille doit avoir une réponse avant que gzip vaille le coup ?
Le seuil habituel est de 256 octets à 1 Ko. Une réponse aussi petite que {"ok": true} — 16 octets avec indentation — grossit à 36 octets sous gzip -6, car le dictionnaire et les en-têtes coûtent plus qu'ils ne récupèrent. C'est précisément ce que contrôle gzip_min_length.