Smetti di fare compressione JSON nell'ordine sbagliato: minify vs gzip vs brotli, misurati
In sintesi: Il minify riduce del 30-50% il JSON grezzo, ma gzip cancella la maggior parte di quel guadagno. Numeri misurati su forme reali, più i quattro casi in cui il minify peggiora le cose.

In sintesi: separa prima la compressione del trasporto dalla rifinitura strutturale. Se gzip o brotli non sono ancora attivi, correggi la configurazione del server — gzip -6 da solo raggiunge il 94,5% e lascia al minify un valore di appena 0,9 punti. Il 46,3% pieno del minify si realizza solo quando il conto dei byte viene registrato localmente: corpi delle richieste, i 6 MB di Lambda, 1 MiB di Firestore, o JSON scritto su disco e nei log.
Il badge "risparmiato il 35%" sul tuo minificatore preferito è onesto - misura i byte grezzi. La tua API gira sopra gzip o brotli, e quei due mangiano esattamente la stessa ridondanza. Moltiplicare i due numeri dà la risposta sbagliata.
1. Dividere "compressione" in due cose diverse
Quella che la gente chiama compressione JSON è in realtà due operazioni non correlate, e confonderle è come inizia ogni conclusione sbagliata.
| minify (rimuove gli spazi) | gzip / brotli (compressione del trasporto) | |
|---|---|---|
| Natura | Una trasformazione del documento | Una trasformazione del trasporto |
| Output | Testo JSON ancora valido e leggibile | Binario; inutilizzabile finché non decodificato |
| Dove atterra il guadagno | Disco, memoria, log, limiti di dimensione | Solo sul filo e nelle cache |
| Dopo la decodifica | I byte restano più piccoli | I byte tornano alla dimensione originale |
| Chi lo possiede | Il tuo script di build, gli argomenti del serializzatore | Nginx, CDN, framework del server |
Le ultime due righe sono quelle che contano. I limiti delle piattaforme sono misurati dopo la decodifica: il tetto di 6 MB del payload sincrono di AWS Lambda, il tetto di 1 MiB per documento di Firestore. Attivare gzip non compra un solo byte lì, perché entrambi contano il risultato decodificato.
2. Il minify rimuove esattamente una cosa: gli spazi strutturali
RFC 8259 consente gli spazi (space, \t, \n, \r) attorno a qualsiasi carattere strutturale. Nient'altro in un documento JSON è opzionale: i nomi delle chiavi sono dati, i numeri conservano ogni cifra, e il contenuto delle stringhe è intoccabile byte per byte.
Questo rende l'intera operazione una riga:
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
Vale la pena segnalare tre operazioni che vengono classificate come "compressione" ma in realtà modificano il documento: eliminare le chiavi il cui valore è null, arrotondare i float e riordinare le chiavi. Queste cambiano il documento invece di riformattarlo, quindi qualsiasi strumento che pubblicizza una "compressione profonda" ha bisogno del benestare del tuo consumatore prima che tu lo abiliti.
Poiché il minify rimuove solo gli spazi, il suo rendimento dipende quasi interamente da quanta parte del tuo file è indentazione. Aspettati che questo si ripeta nelle tabelle qui sotto.
3. La misurazione: quattro documenti, tre livelli gzip, più brotli
Ambiente: Node v22.13.1, zlib.gzipSync ai livelli 1 / 6 / 9, brotliCompressSync alla sua qualità predefinita (11). I quattro campioni coprono deliberatamente le forme che spedisci davvero: 50 ordini annidati (struttura profonda, chiavi ripetute), una configurazione a 6 livelli (chiavi lunghe, valori brevi), un array di log di 200 righe (stringhe lunghe, quasi nessuna indentazione) e il vero media-manifest.json di questo sito (forma mista, stringhe povere di URL).
Passo uno: byte grezzi.
| Campione | pretty (2 spazi) | pretty (4 spazi) | dopo minify | minify risparmia |
|---|---|---|---|---|
| 50 ordini annidati | 29.551 B | 40.761 B | 15.881 B | 46,3% |
| Configurazione a 6 livelli | 25.450 B | 38.012 B | 11.679 B | 54,1% |
| Array di log di 200 righe | 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% |
È da qui che viene quel badge "risparmiato il 35%", e non mente. Il problema compare al passo due.
Passo due: comprimere ogni variante, poi chiedere quanto aggiunge ancora il minify.
| Campione | sopra gzip -1 | sopra gzip -6 | sopra gzip -9 | sopra brotli |
|---|---|---|---|---|
| 50 ordini annidati | 21,0% | 17,0% | 8,9% | 10,6% |
| Configurazione a 6 livelli | 74,2% | 59,1% | 58,0% | 56,4% |
| Array di log di 200 righe | -0,7% | -0,4% | 0,6% | -0,3% |
| media-manifest.json | 1,5% | 1,0% | 1,0% | 1,6% |
Lo stesso documento di ordini annidati che perde il 46,3% dei byte grezzi conserva solo il 17,0% di quel guadagno una volta che gzip -6 entra in gioco. L'array di log va decisamente in negativo: minify-poi-compressione è leggermente più grande della compressione dell'originale pretty. Il negativo non è un bug, è esattamente ciò che dovresti aspettarti, e la sezione successiva spiega perché.
Passo tre: riduzione totale rispetto al documento pretty originale - il numero che viaggia davvero sul filo.
| Campione | solo gzip -6 | minify + gzip -6 | minify + brotli |
|---|---|---|---|
| 50 ordini annidati | 94,5% | 95,4% | 96,9% |
| Configurazione a 6 livelli | 96,0% | 98,4% | 99,0% |
| Array di log di 200 righe | 89,9% | 89,9% | 95,0% |
| media-manifest.json | 83,5% | 83,6% | 86,1% |
4. Perché gzip mangia il credito del minify
gzip è LZ77 seguito dalla codifica di Huffman. La mossa centrale di LZ77 è "guarda indietro attraverso una finestra di 32 KB, trova una sequenza ripetuta, sostituiscila con un puntatore (distanza, lunghezza)". L'indentazione è il bersaglio più facile possibile per quella mossa: ogni livello di annidamento emette una sequenza identica di spazi, e dozzine di righe adiacenti ripetono la stessa combinazione. Huffman assegna poi codici brevi ai simboli ad alta frequenza, quindi un pattern come \n più otto spazi - che può ricorrere mille volte in un singolo file - finisce per costare due o tre bit.
Quindi i byte che il minify elimina sono precisamente i byte che il compressore avrebbe compresso quasi gratis. Farlo da solo significa rifare un lavoro in cui il compressore eccelle già, e il tuo 46% collassa a 17%. È tutta la storia dietro la tabella sopra.
Il risultato negativo dell'array di log merita una spiegazione propria. Quel campione è quasi interamente stringhe lunghe (righe di log, URL, base64), il che significa poche ripetizioni e alta entropia - poca ridondanza da rimuovere in primo luogo. I newline nella versione pretty danno per caso al matcher confini stabili. Rimuoverli e LZ77 perde quegli ancoraggi: in questo campione, comprimere l'originale pretty a gzip -1 produce 1.508 byte, mentre minify-poi-compressione produce 1.518 byte. Dieci byte peggio.
Ordinati per grandezza, le leve si allineano così:
| Leva | Risparmio rispetto al documento pretty originale (ordini annidati) | Costo |
|---|---|---|
| minify | 46,3% (solo byte grezzi) | Leggibilità persa; richiede un passo di build |
| gzip -6 | 94,5% | Un po' di CPU del server; configurazione quasi nulla |
| minify + gzip -6 | 95,4% | I due sopra, sommati |
| minify + brotli | 96,9% | Richiede HTTPS e il modulo brotli; pre-compressione degli asset statici |
Il salto da 0 a 94,5% è il lavoro di gzip. Il salto da 94,5% a 95,4% - nove decimi di punto - è del minify. Questa tabella stabilisce dove appartiene il tuo sforzo.
5. Il livello di compressione è la variabile nascosta
Qualsiasi argomento che assuma un singolo livello gzip fisso sta sulla sabbia, perché il livello cambia direttamente il valore marginale del minify:
| Impostazione del trasporto | Byte extra che il minify risparmia ancora (ordini annidati) |
|---|---|
gzip -1 (default di gzip_comp_level in Nginx) |
21,0% |
| gzip -6 (default nella maggior parte delle librerie standard) | 17,0% |
| gzip -9 | 8,9% |
| brotli (qualità predefinita) | 10,6% |
Più debole è la compressione del trasporto, più il minify rende. Questo ti dà una regola pratica: su un'installazione standard di Nginx dove gzip_comp_level è ancora 1, il minify ti dà il 21%. Una volta regolato a 9 o passato a brotli, scende al 9-11% e di solito puoi saltarlo.
Prima di discutere in un modo o nell'altro, misura a quale livello sei davvero:
# 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
Tre numeri rispondono a tre domande in una volta: la compressione è attiva, a quale livello, e quanto spazio resta per il minify. Se identity e gzip tornano quasi identici, la compressione non è affatto attiva - e la cosa da correggere è Nginx, non il tuo 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. Quando il minify rende davvero
Un test lo decide: i byte che hai risparmiato verranno registrati da qualche parte, o incontrerai un limite che conta i byte grezzi? Se sì, atterra il 46% pieno. Se la risposta è "esistono solo sul filo", allora il 17% è il suo valore reale.
Primo, i corpi delle richieste HTTP. La maggior parte dei server non decomprime i corpi delle richieste, e gzip è configurato quasi esclusivamente per le risposte. Il JSON che fai POST di solito non ha alcuna protezione a livello di trasporto, quindi i risparmi del minify arrivano a prezzo pieno. Lo stesso vale per i callback dei webhook, la telemetria MQTT e i frame WebSocket che non hanno negoziato permessage-deflate.
Secondo, i canali con un tetto massimo di byte - e ognuno di essi conta byte decodificati:
| Canale | Limite | gzip può aiutare? |
|---|---|---|
| Payload sincrono di AWS Lambda | 6 MB | No, misurato dopo la decodifica |
| Singolo documento Firestore | 1 MiB | No |
| Singolo messaggio Kafka (default) | 1 MB | No, la compressione lato broker è un altro strato |
| Quota di localStorage | ~5 MB (contati in caratteri) | No |
| URL di richiesta GET | Dipende dal server, 8 KB è comune | No |
In questa categoria il minify non è un'ottimizzazione, è la differenza tra una richiesta che viene inviata e una che fallisce. Incorporare JSON in una query string URL è il caso più netto: gli URL non sono mai compressi e hanno un limite di lunghezza, quindi fai prima il minify, poi decidi se hai affatto bisogno del base64.
Terzo, il JSON scritto su disco o nei log. Log e indici memorizzano byte letterali senza alcuno strato di trasporto coinvolto. C'è un'avvertenza che coglie le persone impreparate: il minify aiuta solo se stavi scrivendo JSON pretty fin dall'inizio. Quel campione di log di 200 righe risparmiava appena il 4,1%, perché le sue righe erano già compatte e l'indentazione costituiva quasi nulla del file. Al contrario, se il tuo logger chiama JSON.stringify(obj, null, 2), eliminare il terzo argomento è denaro gratis.
Un'ultima nota: quando i byte grezzi sono davvero stretti, i nomi delle chiavi sono di solito una leva più grande dell'indentazione. Su media-manifest.json, sostituire le sue 14 chiavi uniche con k0 fino a k13 porta la forma minificata da 21.222 B a 18.887 B - un altro 11,0%. Compressa, quel margine rimanente si riduce al 2,4% sotto gzip -6 e all'1,3% sotto brotli. Stesso meccanismo di tutto quanto sopra: indentazione e nomi delle chiavi sono entrambi informazioni strutturali ripetute, e il compressore incassa lo stesso dividendo in entrambi i casi.
7. Quattro casi in cui si ritorce contro
Non minificare i file che committi. Cambia "qty": 2 in 3 e il file pretty mostra una riga pulita nel git diff; il file minificato riscrive l'intera riga, e con centinaia di campi non riesci a trovare affatto la modifica. È la stessa meccanica trattata in Come risolvere i conflitti di merge JSON - gli strumenti di diff operano riga per riga, quindi un JSON su una sola riga trasforma ogni modifica in "l'intera riga differisce". Mantieni i file pretty nel repository e minifica solo nell'output di build o quando scrivi su archiviazione.
Non comprimere risposte minuscole. Una risposta che contiene solo {"ok": true} è di 16 byte una volta aggiunta l'indentazione, e di 36 byte dopo gzip -6 - venti byte in più, perché il dizionario e gli header di gzip occupano spazio. Non è teorico; è esattamente per questo che esiste gzip_min_length. Il suo default in Nginx di 20 byte è troppo basso per la produzione, dove 256 è il pavimento usuale.
Non aspettarti un parsing più veloce. Scalando lo stesso insieme di ordini a 4.000 record, il documento pretty è di 2.374.378 byte contro 1.282.358 minificati - il 46% più piccolo - eppure il tempo mediano di JSON.parse passa solo da 5,7 ms a 5,0 ms, circa il 12% più veloce. Dimezzare la dimensione non dimezza il tempo, perché il parser spende la maggior parte del suo budget a ritagliare stringhe e convertire numeri; saltare gli spazi è incidentale. Ciò che guida davvero il costo del parsing sono il numero di chiavi e il contenuto delle stringhe, che Parsing, rendering e streaming di file JSON di grandi dimensioni scompone in dettaglio.
Non applicarlo a ritroso su un servizio che già gira con brotli o gzip -9. Il rendimento scende al 9-11%, mentre il costo - perdita permanente di leggibilità quando fai debug - resta. Controlla invece se gzip_types copre affatto application/json. Molti siti abilitano gzip e comprimono solo text/html, lasciando JSON completamente non compresso; in quella situazione il minify non è il passo uno, lo è correggere la configurazione.
8. Tabella decisionale: quale passo per primo
| La tua situazione | Fai questo per primo | Dove si colloca il minify |
|---|---|---|
| Nessuna compressione del trasporto | Configura gzip_types di Nginx o abilita la compressione automatica della CDN |
Saltalo; nove decimi del guadagno evaporano |
| Gira gzip -1 (default Nginx) | Alza gzip_comp_level a 6 |
Secondo, vale il 21% |
| Già su brotli o gzip -9 | Esamina le chiavi lunghe, aggiungi paginazione o taglia campi | Saltalo; restano solo il 9-11% |
| Raggiungi i 6 MB di Lambda o 1 MiB di Firestore | Minifica direttamente; conta il 46% pieno | Primo |
| Corpi POST, webhook, MQTT | Minifica direttamente; il trasporto qui si applica raramente | Primo |
| File di configurazione destinati a Git | Mantienili pretty | Solo nell'output di build |
| Log, telemetria, dump offline | Rimuovi l'argomento null, 2 nel tuo logger |
Questo è il passo uno |
La regola in una frase: guarda quale bilancio di byte stai cercando di ridurre - uno registrato localmente (disco, quota, limite di piattaforma, corpo della richiesta) o uno che esiste solo sul filo. Locale prima significa minify; solo sul filo significa attivare prima brotli o gzip.
In pratica, esegui il ciclo curl sopra per confermare che la compressione del trasporto è attiva, poi passa un campione attraverso lo strumento Minify JSON e leggi la sua cifra di risparmio. Se quel numero è sopra il 40% mentre curl riporta identity e gzip a dimensioni quasi identiche, il tuo collo di bottiglia è la configurazione del server, non JSON. Se i due numeri di curl sono già vicini, questo documento ha poco altro da dare e dovresti invece tagliare campi o paginare - l'argomento di Nozioni essenziali di progettazione dei contratti API JSON.
Il JSON minificato è ancora JSON valido?
Sì. Il minify rimuove solo gli spazi che RFC 8259 consente attorno ai caratteri strutturali e non tocca mai il contenuto delle stringhe, quindi JSON.parse produce un valore identico all'originale. L'unica vittima è la leggibilità umana.
brotli o gzip?
Sullo stesso campione minificato, brotli risparmia il 96,9% e gzip -6 il 95,4% - un divario stretto. Usa gzip -6 per le risposte dinamiche per tenere bassa la CPU, e riserva brotli agli asset statici serviti via HTTPS che puoi pre-comprimere in file .br.
Ho minificato il mio JSON ma l'API non è diventata più veloce. Perché?
Controlla la risposta con curl -sI per un header Content-Encoding: gzip. Molte configurazioni Nginx impostano gzip_types di default solo su text/html, quindi JSON non è mai stato coperto - e meno di un quinto del tuo 46% ha mai raggiunto il filo.
I file di configurazione dovrebbero essere committati minificati?
No. Gli strumenti di diff confrontano riga per riga, quindi un JSON su una sola riga trasforma ogni modifica in "l'intera riga è cambiata", e risolvere i conflitti costa molto più dei byte risparmiati. Mantieni le versioni pretty nel repository e minifica al momento della build.
Quanto grande dovrebbe essere una risposta prima che gzip valga la pena?
La soglia usuale è da 256 byte a 1 KB. Una risposta piccola come {"ok": true} - 16 byte con indentazione - cresce a 36 byte sotto gzip -6, perché il dizionario e gli header costano più di quanto recuperano. È precisamente ciò che controlla gzip_min_length.