Przestań kompresować JSON w złej kolejności: minifikacja, gzip i brotli w pomiarach
Podsumowanie: Minifikacja usuwa 30–50% surowego JSON-a, ale gzip wymazuje większość tego zysku. Zmierzone liczby dla realnych kształtów plus cztery przypadki, w których minifikacja pogarsza sprawę.

Najważniejsze: najpierw rozdziel kompresję transportową od strukturalnego przycinania. Jeśli gzip lub brotli nie jest jeszcze włączony, napraw konfigurację serwera — sam gzip -6 osiąga 94,5% i sprawia, że minifikacja jest warta zaledwie 0,9 punktu. Pełne 46,3% minifikacji liczy się tylko wtedy, gdy rachunek bajtów jest zapisywany lokalnie: treści żądań, 6 MB Lambdy, 1 MiB Firestore czy JSON zapisywany na dysk i do logów.
Odznaka „zaoszczędzono 35%” na ulubionym minifikatorze jest uczciwa — mierzy surowe bajty. Twoje API działa na gzip lub brotli, a te dwa zjadają dokładnie tę samą redundancję. Pomnóż dwie liczby, a dostaniesz złą odpowiedź.
1. Rozdziel „kompresję” na dwie różne rzeczy
To, co ludzie nazywają kompresją JSON, to naprawdę dwie niepowiązane operacje, a mieszanie ich jest źródłem każdego błędnego wniosku.
| minifikacja (usunięcie białych znaków) | gzip / brotli (kompresja transportowa) | |
|---|---|---|
| Natura | Przekształcenie dokumentu | Przekształcenie transportu |
| Wynik | Nadal poprawny, nadal czytelny tekst JSON | Binarny; bezużyteczny przed dekodowaniem |
| Gdzie ląduje zysk | Dysk, pamięć, logi, limity rozmiaru | Tylko na łączu i w cache |
| Po dekodowaniu | Bajty pozostają mniejsze | Bajty wracają do oryginalnego rozmiaru |
| Kto to posiada | Skrypt budowania, argumenty serializatora | Nginx, CDN, framework serwera |
Ostatnie dwa wiersze mają znaczenie. Limity platformy są mierzone po dekodowaniu: limit ładunku AWS Lambda 6 MB, limit 1 MiB per dokument w Firestore. Włączenie gzip nie kupuje tam ani jednego bajtu, bo oba liczą wynik zdekodowany.
2. Minifikacja usuwa dokładnie jedną rzecz: białe znaki strukturalne
RFC 8259 pozwala na białe znaki (space, \t, \n, \r) wokół dowolnego znaku strukturalnego. Nic innego w dokumencie JSON nie jest opcjonalne: nazwy kluczy są danymi, liczby zachowują każdą cyfrę, a zawartość łańcuchów jest nietykalna bajt w bajt.
To czyni całą operację jedną linią:
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
Warto wyróżnić trzy operacje zaliczane do „kompresji”, które faktycznie modyfikują dokument: usuwanie kluczy o wartości null, zaokrąglanie ułamków i zmienianie kolejności kluczy. One zmieniają dokument zamiast go przeformatować, więc każde narzędzie reklamujące „głęboką kompresję” potrzebuje błogosławieństwa konsumenta, zanim je włączysz.
Ponieważ minifikacja usuwa tylko białe znaki, jej zysk zależy niemal wyłącznie od jak duża część pliku to wcięcia. Spodziewaj się, że będzie to widoczne wielokrotnie w poniższych tabelach.
3. Pomiar: cztery dokumenty, trzy poziomy gzip plus brotli
Środowisko: Node v22.13.1, zlib.gzipSync na poziomie 1 / 6 / 9, brotliCompressSync w domyślnej jakości (11). Cztery próbki celowo pokrywają kształty, które naprawdę wdrażasz: 50 zagnieżdżonych zamówień (głęboka struktura, powtarzane klucze), 6-poziomowa konfiguracja (długie klucze, krótkie wartości), tablica 200 linii logu (długie łańcuchy, prawie żadnych wcięć) i prawdziwy media-manifest.json z tej witryny (mieszany kształt, łańcuchy pełne URL).
Krok pierwszy: surowe bajty.
| Próbka | pretty (2 spacje) | pretty (4 spacje) | po minifikacji | minifikacja oszczędza |
|---|---|---|---|---|
| 50 zagnieżdżonych zamówień | 29 551 B | 40 761 B | 15 881 B | 46,3% |
| 6-poziomowa konfiguracja | 25 450 B | 38 012 B | 11 679 B | 54,1% |
| tablica 200 linii logu | 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% |
Stąd bierze się odznaka „zaoszczędzono 35%” i ona nie kłamie. Problem pojawia się w kroku drugim.
Krok drugi: skompresuj każdy wariant, a potem zapytaj, ile minifikacja jeszcze dokłada.
| Próbka | na gzip -1 | na gzip -6 | na gzip -9 | na brotli |
|---|---|---|---|---|
| 50 zagnieżdżonych zamówień | 21,0% | 17,0% | 8,9% | 10,6% |
| 6-poziomowa konfiguracja | 74,2% | 59,1% | 58,0% | 56,4% |
| tablica 200 linii logu | -0,7% | -0,4% | 0,6% | -0,3% |
| media-manifest.json | 1,5% | 1,0% | 1,0% | 1,6% |
Ten sam dokument zagnieżdżonych zamówień, który traci 46,3% surowych bajtów, zachowuje tylko 17,0% z tego, gdy w grę wchodzi gzip -6. Tablica logu wychodzi wręcz ujemna: minifikacja-then-kompresja jest nieco większa niż kompresja oryginału pretty. Ujemny wynik to nie bug, to dokładnie to, czego należy się spodziewać, a następna sekcja wyjaśnia dlaczego.
Krok trzeci: całkowita redukcja względem oryginalnego dokumentu pretty — liczba, która naprawdę podróżuje przez łącze.
| Próbka | tylko gzip -6 | minifikacja + gzip -6 | minifikacja + brotli |
|---|---|---|---|
| 50 zagnieżdżonych zamówień | 94,5% | 95,4% | 96,9% |
| 6-poziomowa konfiguracja | 96,0% | 98,4% | 99,0% |
| tablica 200 linii logu | 89,9% | 89,9% | 95,0% |
| media-manifest.json | 83,5% | 83,6% | 86,1% |
4. Dlaczego gzip zjada kredyt minifikacji
gzip to LZ77, a następnie kodowanie Huffmana. Rdzeń LZ77 brzmi: „spójrz wstecz w oknie 32 KB, znajdź powtarzającą się sekwencję, zastąp ją wskaźnikiem (odległość, długość)”. Wcięcia są najłatwiejszym celem tej operacji: każdy poziom zagnieżdżenia emituje identyczny ciąg spacji, a dziesiątki sąsiednich linii powtarzają tę samą kombinację. Huffman przydziela potem krótkie kody symbolom o wysokiej częstotliwości, więc wzór taki jak \n plus osiem spacji — mogący powtarzać się tysiąc razy w jednym pliku — kosztuje dwa lub trzy bity.
Więc bajty, które minifikacja usuwa, są dokładnie tymi bajtami, które kompresor zwinąłby niemal za darmo. Robienie tego samodzielnie oznacza powtarzanie pracy, w której kompresor już celuje, a twoje 46% kurczy się do 17%. To cała historia za powyższą tabelą.
Ujemny wynik tablicy logu zasługuje na osobne wyjaśnienie. Ta próbka składa się niemal wyłącznie z długich łańcuchów (linie logu, URL-e, base64), co oznacza mało powtórzeń i wysoką entropię — mało redundancji do usunięcia przez kompresor. Nowe linie w wersji pretty przypadkiem dają matcherowi stabilne granice. Usuń je, a LZ77 traci te kotwice: w tej próbce kompresja oryginału pretty przy gzip -1 daje 1 508 bajtów, podczas gdy minifikacja-then-kompresja daje 1 518 bajtów. Dziesięć bajtów gorzej.
Uszeregowane według wielkości, dźwignie układają się tak:
| Dźwignia | Oszczędność względem oryginału pretty (zagnieżdżone zamówienia) | Koszt |
|---|---|---|
| minifikacja | 46,3% (tylko surowe bajty) | Czytelność znika; wymaga etapu budowania |
| gzip -6 | 94,5% | Trochę CPU serwera; niemal zerowa konfiguracja |
| minifikacja + gzip -6 | 95,4% | Suma dwóch powyższych |
| minifikacja + brotli | 96,9% | Wymaga HTTPS i modułu brotli; prekompresja statyków |
Skok z 0 do 94,5% to praca gzip. Skok z 94,5% do 95,4% — dziewięć dziesiątych punktu — to minifikacja. Ta tabela rozstrzyga, gdzie należy twój wysiłek.
5. Poziom kompresji to ukryta zmienna
Każdy argument zakładający jeden stały poziom gzip stoi na piasku, bo poziom bezpośrednio zmienia marginalną wartość minifikacji:
| Ustawienie transportu | Dodatkowe bajty, które minifikacja jeszcze oszczędza (zagnieżdżone zamówienia) |
|---|---|
gzip -1 (domyślne gzip_comp_level w Nginx) |
21,0% |
| gzip -6 (domyślne w większości bibliotek standardowych) | 17,0% |
| gzip -9 | 8,9% |
| brotli (domyślna jakość) | 10,6% |
Im słabsza kompresja transportowa, tym bardziej minifikacja się opłaca. Stąd praktyczna reguła: w standardowej instalacji Nginx, gdzie gzip_comp_level wciąż wynosi 1, minifikacja daje 21%. Gdy dostroisz do 9 albo przejdziesz na brotli, spada do 9–11% i zwykle możesz ją pominąć.
Zanim zaczniesz spierać się w którąkolwiek stronę, zmierz, na którym poziomie faktycznie jesteś:
# 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
Trzy liczby odpowiadają na trzy pytania naraz: czy kompresja w ogóle jest włączona, na jakim poziomie i ile miejsca zostało dla minifikacji. Jeśli identity i gzip wracają niemal identyczne, kompresja w ogóle nie działa — a rzeczą do naprawy jest Nginx, a nie twój 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. Kiedy minifikacja naprawdę się opłaca
Decyduje jeden test: czy zaoszczędzone bajty zostaną gdzieś zapisane, czy trafisz na limit liczący surowe bajty? Jeśli tak, ląduje pełne 46%. Jeśli odpowiedź brzmi „istnieją tylko na łączu”, to 17% jest ich prawdziwą wartością.
Po pierwsze, treści żądań HTTP. Większość serwerów nie dekompresuje treści żądań, a gzip jest skonfigurowany niemal wyłącznie dla odpowiedzi. JSON, który POST-ujesz, zwykle nie ma żadnej ochrony warstwy transportowej, więc oszczędności minifikacji przychodzą w pełnej cenie. To samo dotyczy wywołań zwrotnych webhooków, telemetrii MQTT i ramek WebSocket, które nie wynegocjowały permessage-deflate.
Po drugie, kanały z twardym sufitem bajtowym — i każdy z nich liczy bajty zdekodowane:
| Kanał | Limit | Czy gzip pomoże? |
|---|---|---|
| Ładunek synchroniczny AWS Lambda | 6 MB | Nie, mierzony po dekodowaniu |
| Pojedynczy dokument Firestore | 1 MiB | Nie |
| Pojedyncza wiadomość Kafka (domyślnie) | 1 MB | Nie, kompresja po stronie brokera to inna warstwa |
| Kwota localStorage | ~5 MB (liczona w znakach) | Nie |
| URL żądania GET | Zależny od serwera, często 8 KB | Nie |
W tej kategorii minifikacja nie jest optymalizacją, ale różnicą między żądaniem, które przechodzi, a takim, które zawodzi. Osadzanie JSON w ciągu zapytania URL to najostrzejszy przypadek: URL-e nigdy nie są kompresowane i mają limit długości, więc najpierw minifikuj, a potem zdecyduj, czy w ogóle potrzebujesz base64.
Po trzecie, JSON zapisywany na dysk lub do logów. Logi i indeksy przechowują literalne bajty bez warstwy transportowej. Jest zastrzeżenie, które ludzi łapie: minifikacja pomaga tylko wtedy, gdy od początku pisałeś pretty JSON. Ta próbka 200 linii logu oszczędziła zaledwie 4,1%, bo jej linie były już zwięzłe, a wcięcia stanowiły niemal nic z pliku. I odwrotnie: jeśli twój logger wywołuje JSON.stringify(obj, null, 2), usunięcie trzeciego argumentu to darmowe pieniądze.
Jeszcze jedna uwaga: gdy surowe bajty naprawdę są ciasne, nazwy kluczy są zwykle większą dźwignią niż wcięcia. W media-manifest.json zastąpienie jego 14 unikalnych kluczy przez k0 do k13 zabiera zminifikowaną formę z 21 222 B do 18 887 B — kolejne 11,0%. Po kompresji ta reszta przewagi kurczy się do 2,4% pod gzip -6 i 1,3% pod brotli. Ten sam mechanizm jak wszystko powyżej: wcięcia i nazwy kluczy to powtarzana informacja strukturalna, a kompresor zgarnia tę samą dywidendę w obu przypadkach.
7. Cztery przypadki, w których się to mści
Nie minifikuj plików, które commit-ujesz. Zmień "qty": 2 na 3, a plik pretty pokaże jedną czystą linię w git diff; zminifikowany plik przepisuje całą linię, a przy setkach pól w ogóle nie znajdziesz zmiany. To ta sama mechanika opisana w Jak rozwiązywać konflikty scalania JSON — narzędzia diffu działają linia po linii, więc jednoliniowy JSON zamienia każdą edycję w „cała linia się różni”. Trzymaj pliki pretty w repozytorium i minifikuj tylko w wyniku budowania albo przy zapisie do magazynu.
Nie kompresuj mikrorych odpowiedzi. Odpowiedź zawierająca tylko {"ok": true} ma 16 bajtów po dodaniu wcięć i 36 bajtów po gzip -6 — dwadzieścia bajtów więcej, bo słownik i nagłówki gzip zajmują miejsce. To nie teoria; właśnie dlatego istnieje gzip_min_length. Jego domyślne 20 bajtów w Nginx jest znacznie za niskie dla produkcji, gdzie zwykły dolny próg to 256.
Nie licz na szybsze parsowanie. Skalując ten sam zestaw zamówień do 4 000 rekordów, dokument pretty ma 2 374 378 bajtów wobec 1 282 358 zminifikowanych — 46% mniej — a mimo to mediana czasu JSON.parse przesuwa się tylko z 5,7 ms do 5,0 ms, czyli około 12% szybciej. Połowa rozmiaru nie oznacza połowy czasu, bo parser zużywa większość budżetu na cięcie łańcuchów i konwersję liczb; pomijanie białych znaków jest poboczne. Koszt parsowania naprawdę napędzają liczba kluczy i zawartość łańcuchów, co Parsowanie, renderowanie i strumieniowanie dużego JSON omawia szczegółowo.
Nie dorabiaj tego do usługi, która już działa na brotli lub gzip -9. Zysk spada do 9–11%, a koszt — trwała utrata czytelności podczas debugowania — zostaje. Sprawdź zamiast tego, czy gzip_types w ogóle obejmuje application/json. Wiele witryn włącza gzip i kompresuje tylko text/html, zostawiając JSON całkowicie nieskompresowany; w tej sytuacji minifikacja nie jest krokiem pierwszym, naprawa konfiguracji jest.
8. Tabela decyzyjna: który etap najpierw
| Twoja sytuacja | Zrób najpierw | Gdzie plasuje się minifikacja |
|---|---|---|
| Brak kompresji transportowej | Skonfiguruj gzip_types w Nginx lub włącz automatyczną kompresję CDN |
Pomiń; dziewięć dziesiątych zysku wyparowuje |
| Działa gzip -1 (domyślne Nginx) | Podnieś gzip_comp_level do 6 |
Drugi etap, wart 21% |
| Już na brotli lub gzip -9 | Przejrzyj długie klucze, dodaj paginację lub przytnij pola | Pomiń; zostało tylko 9–11% |
| Trafiasz w Lambda 6 MB lub Firestore 1 MiB | Minifikuj od razu; liczy się pełne 46% | Pierwszy |
| Treści POST, webhooki, MQTT | Minifikuj od razu; transport rzadko tu działa | Pierwszy |
| Pliki konfiguracyjne trafiające do Git | Zostaw je pretty | Tylko w wyniku budowania |
| Logi, telemetria, zrzuty offline | Usuń argument null, 2 w loggerze |
To jest etap pierwszy |
Reguła w jednym zdaniu: spójrz, który rachunek bajtów próbujesz zmniejszyć — ten zapisywany lokalnie (dysk, kwota, limit platformy, treść żądania) czy ten istniejący tylko na łączu. Lokalnie oznacza minifikację; tylko na łączu oznacza najpierw włącz brotli lub gzip.
W praktyce uruchom powyższą pętlę curl, aby potwierdzić, że kompresja transportowa działa, a następnie przepuść próbkę przez narzędzie minifikacji JSON i odczytaj jego liczbę oszczędności. Jeśli liczba jest powyżej 40%, a curl zgłasza identity i gzip o niemal tym samym rozmiarze, twoim wąskim gardłem jest konfiguracja serwera, a nie JSON. Jeśli dwie liczby curl są już blisko siebie, ten dokument ma już niewiele do zaoferowania i powinieneś zamiast tego przycinać pola albo dodawać paginację — temat Kluczowych punktów projektowania kontraktu API JSON.
Czy zminifikowany JSON pozostaje poprawnym JSON-em?
Tak. Minifikacja usuwa tylko białe znaki, które RFC 8259 pozwala wokół znaków strukturalnych, i nigdy nie dotyka zawartości łańcuchów, więc JSON.parse produkuje wartość identyczną z oryginałem. Jedyną ofiarą jest czytelność dla człowieka.
brotli czy gzip?
Na tej samej zminifikowanej próbce brotli oszczędza 96,9%, a gzip -6 oszczędza 95,4% — wąska różnica. Używaj gzip -6 dla dynamicznych odpowiedzi, aby utrzymać CPU w ryzach, a brotli zarezerwuj dla statyków dostarczanych przez HTTPS, które możesz prekompresować do plików .br.
Zminifikowałem JSON, ale API nie stało się szybsze. Dlaczego?
Sprawdź odpowiedź przez curl -sI pod kątem nagłówka Content-Encoding: gzip. Wiele konfiguracji Nginx domyślnie ustawia gzip_types tylko na text/html, więc JSON nigdy nie był objęty — i mniej niż jedna piąta twoich 46% dotarła na łącze.
Czy pliki konfiguracyjne powinny być commitowane zminifikowane?
Nie. Narzędzia diffu porównują linia po linii, więc jednoliniowy JSON zamienia każdą edycję w „cała linia się zmieniła”, a rozwiązywanie konfliktów kosztuje znacznie więcej niż zaoszczędzone bajty. Trzymaj w repozytorium wersje pretty i minifikuj w czasie budowania.
Jak duża musi być odpowiedź, zanim gzip się opłaca?
Zwykły próg to 256 bajtów do 1 KB. Odpowiedź tak mała jak {"ok": true} — 16 bajtów z wcięciami — rośnie do 36 bajtów pod gzip -6, bo słownik i nagłówki kosztują więcej, niż odzyskują. Właśnie to kontroluje gzip_min_length.