Stop Doing JSON Compression in the Wrong Order: minify vs gzip vs brotli, Measured
Bottom line: Minifying cuts 30-50% off raw JSON, but gzip erases most of that gain. Measured numbers across real shapes, plus the four cases where minifying makes things worse.

Bottom line: separate transport compression from structural trimming first. If gzip or brotli is not on yet, fix the server config — gzip -6 alone reaches 94.5% and leaves minify worth just 0.9 of a point. minify's full 46.3% only lands when the byte bill is recorded locally: request bodies, Lambda's 6 MB, Firestore's 1 MiB, or JSON written to disk and logs.
The "saved 35%" badge on your favourite minifier is honest - it measures raw bytes. Your API runs on top of gzip or brotli, and those two eat the exact same redundancy. Multiply the two numbers and you get the wrong answer.
1. Split "compression" into two different things
What people call JSON compression is really two unrelated operations, and mixing them up is how every wrong conclusion starts.
| minify (strip whitespace) | gzip / brotli (transport compression) | |
|---|---|---|
| Nature | A document transform | A transport transform |
| Output | Still valid, still readable JSON text | Binary; unusable until decoded |
| Where the win lands | Disk, memory, logs, size limits | Only on the wire and in caches |
| After decoding | Bytes stay smaller | Bytes return to the original size |
| Who owns it | Your build script, your serializer args | Nginx, CDN, server framework |
The last two rows are the ones that matter. Platform limits are measured after decoding: AWS Lambda's 6 MB payload cap, Firestore's 1 MiB per-document cap. Turning on gzip does not buy you a single byte there, because both count the decoded result.
2. minify removes exactly one thing: structural whitespace
RFC 8259 permits whitespace (space, \t, \n, \r) around any structural character. Nothing else in a JSON document is optional: key names are data, numbers keep every digit, and string contents are byte-for-byte untouchable.
That makes the whole operation one line:
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
Worth calling out are three operations that get filed under "compression" but actually modify the document: dropping keys whose value is null, rounding floats, and reordering keys. Those change the document rather than reformat it, so any tool advertising "deep compression" needs your consumer's blessing before you enable it.
Because minify only removes whitespace, its payoff depends almost entirely on how much of your file is indentation. Expect that to show up repeatedly in the tables below.
3. The measurement: four documents, three gzip levels, plus brotli
Environment: Node v22.13.1, zlib.gzipSync at level 1 / 6 / 9, brotliCompressSync at its default quality (11). The four samples deliberately cover the shapes you actually ship: 50 nested orders (deep structure, repeated keys), a 6-level config (long keys, short values), a 200-line log array (long strings, almost no indentation), and the real media-manifest.json from this site (mixed shape, URL-heavy strings).
Step one: raw bytes.
| Sample | pretty (2 spaces) | pretty (4 spaces) | after minify | minify saves |
|---|---|---|---|---|
| 50 nested orders | 29,551 B | 40,761 B | 15,881 B | 46.3% |
| 6-level config | 25,450 B | 38,012 B | 11,679 B | 54.1% |
| 200-line log array | 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% |
This is where that "saved 35%" badge comes from, and it is not lying. The problem appears in step two.
Step two: compress each variant, then ask how much minify still adds.
| Sample | on top of gzip -1 | on top of gzip -6 | on top of gzip -9 | on top of brotli |
|---|---|---|---|---|
| 50 nested orders | 21.0% | 17.0% | 8.9% | 10.6% |
| 6-level config | 74.2% | 59.1% | 58.0% | 56.4% |
| 200-line log array | -0.7% | -0.4% | 0.6% | -0.3% |
| media-manifest.json | 1.5% | 1.0% | 1.0% | 1.6% |
The same nested orders document that loses 46.3% of its raw bytes keeps only 17.0% of that once gzip -6 is in play. The log array goes outright negative: minify-then-compress is slightly larger than compressing the pretty original. Negative is not a bug, it is exactly what you should expect, and the next section explains why.
Step three: total reduction against the original pretty document - the number that actually travels over the wire.
| Sample | gzip -6 only | minify + gzip -6 | minify + brotli |
|---|---|---|---|
| 50 nested orders | 94.5% | 95.4% | 96.9% |
| 6-level config | 96.0% | 98.4% | 99.0% |
| 200-line log array | 89.9% | 89.9% | 95.0% |
| media-manifest.json | 83.5% | 83.6% | 86.1% |
4. Why gzip eats minify's credit
gzip is LZ77 followed by Huffman coding. The core LZ77 move is "look back through a 32 KB window, find a repeated sequence, replace it with a (distance, length) pointer." Indentation is the easiest possible target for that move: every nesting level emits an identical run of spaces, and dozens of adjacent lines repeat the same combination. Huffman then assigns short codes to high-frequency symbols, so a pattern like \n plus eight spaces - one that may recur a thousand times in a single file - ends up costing two or three bits.
So the bytes minify deletes are precisely the bytes the compressor would have collapsed almost for free. Doing it yourself means redoing work the compressor already excels at, and your 46% collapses to 17%. That is the entire story behind the table above.
The negative log-array result deserves its own explanation. That sample is almost entirely long strings (log lines, URLs, base64), which means few repeats and high entropy - little redundancy for the compressor to remove in the first place. The newlines in the pretty version happen to give the matcher stable boundaries. Remove them and LZ77 loses those anchors: in this sample, compressing the pretty original at gzip -1 yields 1,508 bytes, while minify-then-compress yields 1,518 bytes. Ten bytes worse.
Ranked by magnitude, the levers line up like this:
| Lever | Saved vs. the original pretty document (nested orders) | Cost |
|---|---|---|
| minify | 46.3% (raw bytes only) | Readability gone; needs a build step |
| gzip -6 | 94.5% | A little server CPU; near-zero config |
| minify + gzip -6 | 95.4% | The two above, added up |
| minify + brotli | 96.9% | Needs HTTPS and the brotli module; pre-compress static assets |
The jump from 0 to 94.5% is gzip's work. The jump from 94.5% to 95.4% - nine tenths of a point - is minify's. This table settles where your effort belongs.
5. Compression level is the hidden variable
Any argument that assumes a single fixed gzip level is standing on sand, because the level directly changes minify's marginal value:
| Transport setting | Extra bytes minify still saves (nested orders) |
|---|---|
gzip -1 (Nginx gzip_comp_level default) |
21.0% |
| gzip -6 (default in most standard libraries) | 17.0% |
| gzip -9 | 8.9% |
| brotli (default quality) | 10.6% |
The weaker your transport compression, the more minify pays off. That gives you a practical rule: on a stock Nginx install where gzip_comp_level is still 1, minify hands you 21%. Once you have tuned to 9 or switched to brotli, it is down to 9-11% and you can usually skip it.
Before you argue either way, measure which tier you are actually on:
# 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
Three numbers answer three questions at once: is compression even on, at what level, and how much room is left for minify. If identity and gzip come back nearly identical, compression is not active at all - and the thing to fix is Nginx, not your 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. When minify genuinely pays off
One test decides it: will the bytes you saved be recorded somewhere, or will you hit a limit that counts raw bytes? If yes, the full 46% lands. If the answer is "it only exists on the wire," then 17% is its real value.
First, HTTP request bodies. Most servers do not decompress request bodies, and gzip is configured almost exclusively for responses. The JSON you POST usually has no transport-layer protection at all, so minify's savings arrive at full price. The same holds for webhook callbacks, MQTT telemetry, and WebSocket frames that never negotiated permessage-deflate.
Second, channels with a hard byte ceiling - and every one of them counts decoded bytes:
| Channel | Limit | Can gzip help? |
|---|---|---|
| AWS Lambda synchronous payload | 6 MB | No, measured after decoding |
| Firestore single document | 1 MiB | No |
| Kafka single message (default) | 1 MB | No, broker-side compression is a different layer |
| localStorage quota | ~5 MB (counted in characters) | No |
| GET request URL | Server-dependent, 8 KB is common | No |
In this category minify is not an optimization, it is the difference between a request that submits and one that fails. Embedding JSON in a URL query string is the sharpest case: URLs are never compressed and they do have a length cap, so minify first, then decide whether you need base64 at all.
Third, JSON written to disk or logs. Logs and indexes store literal bytes with no transport layer involved. There is a caveat that trips people up: minify only helps if you were writing pretty JSON to begin with. That 200-line log sample saved just 4.1%, because its lines were already compact and indentation made up almost none of the file. Conversely, if your logger calls JSON.stringify(obj, null, 2), dropping the third argument is free money.
One last note: when raw bytes really are tight, key names are usually a bigger lever than indentation. On media-manifest.json, replacing its 14 unique keys with k0 through k13 takes the minified form from 21,222 B down to 18,887 B - another 11.0%. Compressed, that remaining edge shrinks to 2.4% under gzip -6 and 1.3% under brotli. Same mechanism as everything above: indentation and key names are both repeated structural information, and the compressor claims the same dividend either way.
7. Four cases where it backfires
Do not minify files you commit. Change "qty": 2 to 3 and the pretty file shows one clean line in git diff; the minified file rewrites the entire line, and with hundreds of fields you cannot find the change at all. This is the same mechanics covered in How to Resolve JSON Merge Conflicts - diff tools operate per line, so single-line JSON turns every edit into "whole line differs." Keep pretty files in the repository and minify only in build output or when writing to storage.
Do not compress tiny responses. A response containing only {"ok": true} is 16 bytes once indentation is added, and 36 bytes after gzip -6 - twenty bytes larger, because gzip's own dictionary and headers occupy space. This is not theoretical; it is exactly why gzip_min_length exists. Its Nginx default of 20 bytes is far too low for production, where 256 is the usual floor.
Do not expect faster parsing. Scaling the same order set to 4,000 records, the pretty document is 2,374,378 bytes against 1,282,358 minified - 46% smaller - yet median JSON.parse time only moves from 5.7 ms to 5.0 ms, roughly 12% faster. Halving the size does not halve the time, because the parser spends most of its budget slicing strings and converting numbers; skipping whitespace is incidental. What actually drives parse cost is key count and string content, which Parsing, Rendering and Streaming Large JSON Files breaks down in detail.
Do not retrofit it onto a service already running brotli or gzip -9. The payoff falls to 9-11%, while the cost - permanent loss of readability when you are debugging - stays. Check instead whether gzip_types even covers application/json. Plenty of sites enable gzip and only ever compress text/html, leaving JSON completely uncompressed; in that situation minify is not step one, fixing your config is.
8. Decision table: which step first
| Your situation | Do this first | Where minify ranks |
|---|---|---|
| No transport compression at all | Configure Nginx gzip_types or enable CDN auto-compression |
Skip it; nine tenths of the gain evaporates |
| Running gzip -1 (Nginx default) | Raise gzip_comp_level to 6 |
Second, worth 21% |
| Already on brotli or gzip -9 | Audit long keys, add pagination or trim fields | Skip it; only 9-11% left |
| Hitting Lambda 6 MB or Firestore 1 MiB | Minify directly; the full 46% counts | First |
| POST bodies, webhooks, MQTT | Minify directly; transport rarely applies here | First |
| Config files heading into Git | Keep them pretty | Only in build output |
| Logs, telemetry, offline dumps | Remove the null, 2 argument in your logger |
That is step one |
The one-sentence rule: look at which byte ledger you are trying to shrink - one recorded locally (disk, quota, platform limit, request body) or one that exists only on the wire. Local first means minify; wire-only means turn on brotli or gzip first.
In practice, run the curl loop above to confirm transport compression is live, then push a sample through the JSON Minify tool and read its savings figure. If that number is above 40% while curl reports identity and gzip at nearly the same size, your bottleneck is server configuration, not JSON. If the two curl numbers are already close, this document has little left to give and you should be trimming fields or paginating instead - the subject of JSON API Contract Design Essentials.
Is minified JSON still valid JSON?
Yes. minify removes only the whitespace RFC 8259 allows around structural characters and never touches string contents, so JSON.parse produces a value identical to the original. The only casualty is human readability.
brotli or gzip?
On the same minified sample, brotli saves 96.9% and gzip -6 saves 95.4% - a narrow gap. Use gzip -6 for dynamic responses to keep CPU down, and reserve brotli for HTTPS-delivered static assets you can pre-compress into .br files.
I minified my JSON but the API did not get faster. Why?
Check the response with curl -sI for a Content-Encoding: gzip header. Many Nginx setups default gzip_types to text/html alone, so JSON was never covered - and less than a fifth of your 46% ever reached the wire.
Should config files be committed minified?
No. Diff tools compare line by line, so single-line JSON turns every edit into "the whole line changed," and resolving conflicts costs far more than the bytes you saved. Keep pretty versions in the repository and minify at build time.
How large should a response be before gzip is worth it?
The usual threshold is 256 bytes to 1 KB. A response as small as {"ok": true} - 16 bytes with indentation - grows to 36 bytes under gzip -6, because the dictionary and headers cost more than they recover. That is precisely what gzip_min_length controls.