Plate 22
Nginx Gzip On vs Off: Localhost Wire Size and RPS Lab
Hands-on nginx gzip on/off lab: HTML ~107x smaller at level 1; JSON RPS ~1987 off vs ~1031 l1 vs ~563 l6 on localhost. Measured numbers, no Docker.
Aditya Challa6 min read
Intro — what this post promises
gzip on; looks free until you measure it on a fast path. On a slow WAN, smaller wire bytes win. On localhost (and many LAN/VPC hops), the CPU that builds those bytes can dominate client RPS.
This is a hands-on lab with measured numbers:
- Three nginx masters:
gzip off,gzip_comp_level 1,gzip_comp_level 6. - Wire size and
Content-Encodingfor HTML, JSON, and random binary. - Flood RPS / p50 latency with
Accept-Encoding: gzip. - When level 1 beat level 6 on size for our JSON (yes, really).
- Why
application/octet-streamnever got aContent-Encoding.
Related links:
- Nginx limit_req rate limit lab
- HTTP Keep-Alive vs Connection: close lab
- Why your average latency graph is lying (p50 / p95 / p99)
- How to read server monitoring graphs
Lab honesty (30 Sep 2026 IST): Shared Linux lab box (8 cores, kernel 6.12). nginx/1.26.3 host binary. Three masters on 127.0.0.1:18120–18122 only. Payloads: page.html 159 279 B, data.json 443 791 B, noise.bin 524 288 B random. Client: Python 3.13.5 urllib thread floods, Connection: close. No Docker. No public bind. Affiliates: 0.
Verdict up front: repetitive HTML/JSON compressed ~107× / ~38× at level 1, but JSON floods dropped from ~1987 RPS (off) to ~1031 (l1) and ~563 (l6) on loopback. Gzip is a bandwidth vs CPU trade — measure both.
What nginx gzip changes
With gzip on and a matching gzip_types / default HTML behavior, nginx may compress the response body when the client sends Accept-Encoding: gzip. gzip_comp_level picks the zlib effort. gzip_min_length skips tiny bodies. Types you omit stay uncompressed even if the client asks for gzip.
Related links:
| Knob | Lab setting |
|---|---|
| off | gzip off on :18120 |
| level 1 | gzip on; gzip_comp_level 1 on :18121 |
| level 6 | gzip on; gzip_comp_level 6 on :18122 |
| min length | 64 bytes |
| types | text/css, text/plain, application/json, JS, XML (+ default HTML) |
Lab topology
Arm A — wire size
| File | Off (B) | Level 1 (B) | Level 6 (B) | Ratio l1 |
|---|---|---|---|---|
page.html | 159 279 | 1 491 | 1 282 | ~106.8× |
data.json | 443 791 | 11 584 | 12 222 | ~38.3× |
noise.bin | 524 288 | 524 288 | 524 288 | 1.0× (not gzipped) |
index.html | 3 | 3 | 3 | below gzip_min_length |
noise.bin is application/octet-stream — not in our gzip_types, so no Content-Encoding even with Accept-Encoding: gzip. That is the correct MIME lesson, not a bug.
Odd but measured: for this JSON, zlib level 1 produced a smaller body than level 6 (11 584 vs 12 222). We reproduced the same ordering with Python gzip.compress. Higher gzip_comp_level is not a guaranteed size win — and it cost a lot of CPU below.
Arm B — flood RPS on loopback
| Arm | Path | Wire p50 (B) | Client RPS | Lat p50 |
|---|---|---|---|---|
| off | /data.json | 443 791 | ~1987 | 8.0 ms |
| level 1 | /data.json | 11 584 | ~1031 | 18.5 ms |
| level 6 | /data.json | 12 222 | ~563 | 32.9 ms |
| off | /page.html | 159 279 | ~2123 | 7.6 ms |
| level 1 | /page.html | 1 491 | ~2295 | 5.8 ms |
| level 6 | /page.html | 1 282 | ~1469 | 12.5 ms |
On loopback there is almost no WAN to save. Compressing a ~434 KiB JSON cut RPS roughly 2× at level 1 and 3.5× at level 6. The tiny HTML payload at level 1 was the one case where RPS ticked up slightly — wire shrank enough that transfer+parse won over compress CPU.
Clients that sent Accept-Encoding: identity against the gzip-on masters still got uncompressed bodies (and we saw Vary: Accept-Encoding on the gzip arms).
When gzip still wins the design
This lab is hostile to gzip on purpose: localhost, Connection: close, CPU on the same box as the client. On real edges — mobile, cross-region, expensive egress — a 38× JSON shrink dominates. Prefer:
gzip_comp_level 1–3for dynamic JSON/HTML on fast links.- Precompressed
gzip_staticfor cold assets when you can. - Omit incompressible types; do not burn CPU on noise.
- Pair with keep-alive (separate lab) so you are not also paying handshake tax.
Related links:
Pitfalls we hit (or avoided)
- Assuming higher
gzip_comp_level⇒ smaller — our JSON said otherwise; always weigh size and RPS. - Expecting binary to compress — only listed
gzip_types(plus default HTML) participate. - Judging gzip on loopback alone — RPS can fall while WAN users still win.
- Forgetting
Vary: Accept-Encoding— caches must key on it. - Tiny files —
gzip_min_lengthcorrectly skipped our 3-byte index.
Practical checklist
- Turn gzip on for text/JSON/HTML; leave random/binary types out.
- Start at
gzip_comp_level 1for dynamic responses; raise only after measuring size and CPU/RPS. - Confirm
Content-Encoding: gzipwith a realAccept-Encodingprobe — not just a green config test. - Watch p95/CPU under flood; loopback RPS cliffs predict origin CPU cost.
- Do not confuse this with Brotli/
gzip_static— different knobs, not measured here.
Verdict
Gzip crushed wire size for repetitive HTML/JSON (~107× / ~38× at level 1) but halved to thirded JSON client RPS on localhost versus gzip off. Level 6 was worse on both size (for our JSON) and RPS than level 1. Use gzip where bandwidth matters; keep the level modest on dynamic origins; measure MIME coverage so you are not compressing — or failing to compress — by accident.
Evidence path on the lab box: lab-evidence/20-nginx-gzip/results/. Affiliates: 0.
Lab evidence
What I found running this
Lab 30 Sep 2026 IST. nginx/1.26.3 on 127.0.0.1:18120–18122 (off / level 1 / level 6). page.html 159279→1491 (l1) / 1282 (l6); data.json 443791→11584 (l1) / 12222 (l6) — zlib level 1 smaller than 6 on this JSON; noise.bin 524288 unchanged (octet-stream not in gzip_types). Floods ae=gzip: JSON RPS ~1987 off / ~1031 l1 / ~563 l6; page.html ~2123 / ~2295 / ~1469. Affiliates: 0. Evidence: lab-evidence/20-nginx-gzip/.
Related links
Plate 17
platform vs os.uname Inventory: Localhost Lab
Hands-on platform.platform vs os.uname host inventory lab: real ops/s plus cache notes, measured on Linux localhost today in this hands-on lab for SREs.
1 Oct 2026
Plate 75
uuid.uuid4 vs uuid.uuid1: Localhost Lab
Hands-on uuid.uuid4 vs uuid.uuid1 ID generation lab: real ops/s plus version/node checks, measured on Linux localhost today in this hands-on lab for SREs.
1 Oct 2026
Plate 50
signal vs threading.Event Wakeup: Localhost Lab
Hands-on signal SIGUSR1 vs threading.Event wakeup lab: real p50 latency in microseconds, measured on Linux localhost today in this hands-on lab for SREs.
1 Oct 2026