Compression / Data Encoding

gzip Compression

DEFLATE = LZ77 back-references + Huffman coding · HTTP & APIs · Browser behaviour · Performance & compression levels
01 / What is gzip

Two algorithms working in sequence to shrink data

gzip is a lossless compression format created in 1992 by Jean-loup Gailly and Mark Adler. Under the hood it uses the DEFLATE algorithm, which chains two complementary techniques: LZ77 eliminates repeated substrings by replacing them with (distance, length) back-references, then Huffman coding assigns shorter bit patterns to more-frequent symbols in the resulting stream.

These two stages attack different kinds of redundancy. LZ77 exploits repetition across the input — a word appearing 10× becomes 1 literal + 9 back-references. Huffman exploits frequency imbalance — if 'e' appears far more than 'q', assign 'e' only 2 bits and 'q' 10 bits. Together they typically compress English text to 30–70% of its original size.

The gzip file wrapper adds a 10-byte header (magic bytes 1f 8b, compression method, flags, mtime) and appends a 4-byte CRC32 checksum plus ISIZE (original size mod 232), enabling integrity verification and streaming decompression without seeking.

Core pipeline
gzip(data) = [10-byte header] + DEFLATE(data) + [CRC32 + ISIZE]
DEFLATE(data) = Huffman( LZ77(data) )
LZ77 — replaces repeated substrings with (distance, length) pairs Huffman — assigns shorter codes to more-frequent symbols CRC32 — 32-bit integrity checksum over the original bytes ISIZE — original file size mod 2³² (for stream verification)
gzip vs other compression formats
FormatAlgorithmRatioSpeedHTTP
gzipDEFLATEGoodFastUniversal
brotliLZ77+Huffman+ctxBetterSlowerModern
zstdANS+LZ4BestFastestLimited
lz4LZ4OKFastest
bzip2BWT+HuffmanBetterSlow
xz/lzmaLZMA2BestSlowest
gzip file — byte layout
1f 8b  ← magic number (always)
08     ← CM: deflate method
00     ← FLG: no extra flags
00 00 00 00 ← MTIME (unix timestamp)
00     ← XFL: 0=default, 2=max, 4=fast
ff     ← OS: 255=unknown, 3=Unix
.. .. ..   ← DEFLATE compressed data
xx xx xx xx ← CRC32 (little-endian)
xx xx xx xx ← ISIZE mod 2³²
02 / When it’s used

Everywhere bytes move across a wire or land on disk

gzip is the most ubiquitous compression format in computing. Its balance of speed, ratio, and universal runtime support makes it the default for HTTP transport, log management, data archival, and container distribution.

HTTP Response Compression
Browser sends Accept-Encoding: gzip, br. Server compresses the response body and replies with Content-Encoding: gzip. Browser decompresses transparently — JS/DOM never sees the compressed bytes.
Savings: HTML ~70%, JSON ~80%, CSS/JS ~75%. Default in nginx, Apache, Caddy, Express compression(), FastAPI GZipMiddleware.
Static File Pre-compression
Build pipelines pre-generate .gz versions of every static asset. nginx gzip_static on serves bundle.js.gz instead of bundle.js when the browser supports it — zero CPU per request.
Webpack CompressionPlugin / Vite vite-plugin-compression: generates .gz (and .br) at build time. Saves server CPU for high-traffic static sites.
Log Rotation & Archival
logrotate compresses rotated logs with gzip by default. Application logs are extremely compressible — repetitive timestamps, host names, log levels achieve 90–95% compression in production.
/etc/logrotate.conf: compress / delaycompress. Keeps app.log.1.gz, app.log.2.gz... 10 days at ~10× less disk space than uncompressed.
Database Backups & Exports
Database dumps are piped directly through gzip before writing to disk or S3 — the uncompressed dump never touches disk. SQL is extremely repetitive: keywords, column names, quotes.
pg_dump mydb | gzip > backup.sql.gz — streaming, no temp file. Restore: gunzip -c backup.sql.gz | psql mydb. Typical ratio: 8:1 on SQL dumps.
REST APIs & gRPC
Large JSON list endpoints (/users?limit=10000) compress 80%+ because JSON field names repeat on every record. gRPC supports per-message or per-channel compression. Always check if the client sends Accept-Encoding before compressing.
Express: compression() middleware. gRPC Go: grpc.WithDefaultCallOptions(grpc.UseCompressor(gzip.Name)). Only worthwhile above ~1KB.
Docker & OCI Image Layers
Docker image layers are tarballs compressed with gzip (.tar.gz). Every push/pull transfers gzip streams. The OCI spec uses gzip by default; newer tooling (containerd 1.6+) supports zstd for faster decompression on pull.
node_modules layer: ~120 MB uncompressed → ~32 MB gzipped. docker inspect shows both sizes. Use .dockerignore to reduce layer content first.

HTTP gzip — full request/response cycle

1 — Browser
Accept-Encoding:
gzip, br, deflate
2 — Server checks
Client supports gzip?
Content-Type compressible?
Size > threshold?
3 — Compress
DEFLATE(body)
+ gzip wrapper
CPU cost here
4 — Response headers
Content-Encoding: gzip
Vary: Accept-Encoding
Content-Length: <compressed>
5 — Browser decodes
Transparent —
fetch()/XHR/img
sees original bytes
6 — CDN cache
Stores both gzip &
plain versions keyed
on Vary header

Do not compress: already-compressed formats (JPEG, PNG, MP4, ZIP, WebP) — they will not shrink and CPU is wasted. Set a minimum size threshold (~1 KB) to avoid compressing tiny responses where the 18-byte gzip wrapper is disproportionately large. Always set Vary: Accept-Encoding so CDNs cache separate gzip and plain copies.

03 / How it’s used in code

Compress, decompress, and stream in three languages

All major runtimes ship gzip in the standard library. The golden rule: always stream — pipe data through a gzip transform rather than buffering the whole payload. For HTTP middleware, a single line enables automatic compression. For upload/ingest APIs, check Content-Encoding: gzip and decode before processing.

// ── Compress / decompress a buffer ────────────────────────────
const zlib = require('zlib'), { promisify } = require('util');
const gzip   = promisify(zlib.gzip);
const gunzip = promisify(zlib.gunzip);

const original   = Buffer.from('Hello Hello World Hello');
const compressed = await gzip(original, { level: 6 });      // level 1-9
const restored   = await gunzip(compressed);
console.log(compressed.length, 'bytes compressed');

// ── Stream a file (never loads fully into memory) ─────────────
const fs = require('fs');
fs.createReadStream('access.log')
  .pipe(zlib.createGzip({ level: 6 }))
  .pipe(fs.createWriteStream('access.log.gz'));

// ── Express: auto-compress all responses above 1 KB ───────────
const compression = require('compression');
app.use(compression({ threshold: 1024, level: 6 }));

// ── Hono / Fastify / Next.js ───────────────────────────────────
// Fastify: fastify.register(require('@fastify/compress'), { global: true })
// Next.js: compress: true in next.config.js (default enabled)

// ── Accept a gzip-encoded POST body ───────────────────────────
app.post('/ingest', (req, res) => {
  if (req.headers['content-encoding'] === 'gzip') {
    const chunks = [];
    req.pipe(zlib.createGunzip())
      .on('data', d => chunks.push(d))
      .on('end', () => process(Buffer.concat(chunks)));
  }
});

// ── Decompress with size limit (gzip bomb protection) ─────────
const safe = await gunzip(untrustedBuf, { maxOutputLength: 10 * 1024 * 1024 });
# ── Compress / decompress in memory ────────────────────────────
import gzip

original   = b'Hello Hello World Hello'
compressed = gzip.compress(original, compresslevel=6)   # 1-9
restored   = gzip.decompress(compressed)
print(len(compressed), 'bytes')

# ── Read / write .gz files ──────────────────────────────────────
with gzip.open('access.log.gz', 'wt', compresslevel=6) as f:
    f.write('log line\n' * 100_000)

with gzip.open('access.log.gz', 'rt') as f:   # 'rt' = text, auto-decode
    for line in f:
        process(line)

# ── Stream compress (no full buffer in memory) ──────────────────
import shutil
with open('dump.sql', 'rb') as src, gzip.open('dump.sql.gz', 'wb') as dst:
    shutil.copyfileobj(src, dst, length=65536)  # 64 KB chunks

# ── FastAPI middleware: auto-compress responses >= 1 KB ─────────
from fastapi import FastAPI
from starlette.middleware.gzip import GZipMiddleware
app = FastAPI()
app.add_middleware(GZipMiddleware, minimum_size=1000)

# ── requests: transparently decodes gzip responses ─────────────
import requests
r = requests.get('https://api.example.com/data')
print(r.text)  # already decompressed; r.headers['Content-Encoding'] was 'gzip'

# ── Decode a gzip payload (e.g. from S3 / SQS event) ───────────
import base64
raw  = base64.b64decode(event['body'])
data = gzip.decompress(raw).decode('utf-8')

# ── Protect against gzip bombs ──────────────────────────────────
import io
MAX = 10 * 1024 * 1024   # 10 MB
with gzip.open(io.BytesIO(untrusted_bytes)) as f:
    data = f.read(MAX + 1)
    if len(data) > MAX: raise ValueError('gzip bomb')
// ── Compress / decompress in memory ────────────────────────────
import ("bytes"; "compress/gzip"; "io")

func Compress(data []byte, level int) ([]byte, error) {
    var buf bytes.Buffer
    w, _ := gzip.NewWriterLevel(&buf, level) // gzip.BestSpeed=1 .. gzip.BestCompression=9
    defer w.Close()
    w.Write(data)
    w.Close()
    return buf.Bytes(), nil
}

func Decompress(data []byte) ([]byte, error) {
    r, err := gzip.NewReader(bytes.NewReader(data))
    if err != nil { return nil, err }
    defer r.Close()
    return io.ReadAll(io.LimitReader(r, 10<<20)) // 10 MB limit
}

// ── Stream a file ──────────────────────────────────────────────
src, _ := os.Open("access.log")
dst, _ := os.Create("access.log.gz")
w, _   := gzip.NewWriterLevel(dst, gzip.DefaultCompression)
io.Copy(w, src); w.Close(); src.Close(); dst.Close()

// ── HTTP handler: decode incoming gzip body ────────────────────
func handler(w http.ResponseWriter, r *http.Request) {
    body := r.Body
    if r.Header.Get("Content-Encoding") == "gzip" {
        gr, err := gzip.NewReader(r.Body)
        if err != nil { http.Error(w, "bad gzip", 400); return }
        defer gr.Close(); body = gr
    }
    data, _ := io.ReadAll(io.LimitReader(body, 10<<20))
    // process data...
}

// ── HTTP handler: send gzip response ──────────────────────────
if strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
    w.Header().Set("Content-Encoding", "gzip")
    w.Header().Set("Vary", "Accept-Encoding")
    gz, _ := gzip.NewWriterLevel(w, gzip.DefaultCompression)
    defer gz.Close()
    json.NewEncoder(gz).Encode(payload)
} else {
    json.NewEncoder(w).Encode(payload)
}
// Or use nytimes/gziphandler / klauspost/compress for drop-in middleware
// Security & pitfall notes
CRIME / BREACH attacks

Never compress HTTPS responses that mix secret tokens (CSRF, session IDs) with attacker-controlled input (query params, POST bodies). Compression leaks secret length via ciphertext size changes. CRIME exploits TLS-layer compression; BREACH exploits HTTP-layer. Fix: disable gzip on pages with secrets, or use per-request CSRF token masking (randomise the secret before compressing).

gzip bombs (decompression DoS)

A crafted 1 KB gzip file can expand to 1 GB+. Always set a maximum decompression limit before writing to memory. Node.js: gunzip(buf, {maxOutputLength: 10*1024*1024}). Go: io.LimitReader(r, maxBytes). Python: read incrementally and count bytes. Never decompress untrusted data without a size cap — classic DoS vector in file upload and webhook ingest endpoints.

zlib ≠ gzip ≠ deflate (HTTP confusion)

Three distinct wrappers around the same DEFLATE core. gzip = 10-byte header + DEFLATE + CRC32 + ISIZE. zlib = 2-byte header + DEFLATE + Adler-32. raw deflate = no wrapper. HTTP’s Content-Encoding: deflate historically sends zlib format, not raw DEFLATE — a known ambiguity. Always use Content-Encoding: gzip for HTTP; every browser and CDN handles it correctly.

Double-compression & binary formats

Compressing already-compressed data (JPEG, PNG, MP4, ZIP, .wasm) adds the 18-byte gzip header with zero benefit and often makes the output slightly larger. Check Content-Type before enabling middleware compression. Also: set a minimum_size threshold (~1 KB) everywhere — compressing a 200-byte JSON response wastes more CPU than it saves in transfer time on modern networks.

04 / Performance

Compression levels, benchmarks, and when not to bother

gzip exposes a compression level from 1 (fastest, worst ratio) to 9 (slowest, best ratio). Level 6 is the default — a sweet spot that gets 90% of the compression benefit at ~30% of level-9’s CPU cost. For HTTP responses generated on demand, level 5 or 6 is almost always the right choice. For offline archival, level 9 makes sense.

Compression levels — ratio vs speed tradeoff

LevelCompression ratioRatioSpeed
-1 (fast)
~45%fastest
-3
~54%very fast
-5
~62%fast
-6 (default)
~68%medium ★
-7
~70%slow
-9 (best)
~72%slowest

Ratios shown are approximate for a typical 100 KB JSON payload. Notice that going from level 6 to level 9 gains only ~4% extra compression but can cost 3–4× more CPU. The diminishing returns are steep above level 6.

The streaming principle

Never buffer a full payload to compress it if it can be streamed. Piping a 1 GB log file through createGzip() uses ~256 KB of working memory. Buffering it first requires 1 GB of RAM. gzip is a stream cipher — it processes data in sliding windows. Always prefer pipe() / io.Copy() / shutil.copyfileobj() over compress(wholeBuffer) for anything larger than a few MB.

For HTTP responses in Node.js, the compression() middleware automatically switches to streaming: it wraps res.write() and res.end() so chunks are compressed as they are written, with no full-body buffer required.

Real-world benchmark: 100 KB JSON payload

Format / LevelOutput sizeCompress timeDecompress
Uncompressed100 KB
gzip -1~22 KB0.8 ms0.3 ms
gzip -6 (default)~18 KB2.4 ms0.4 ms
gzip -9~17 KB7.1 ms0.4 ms
brotli -4~16 KB3.2 ms0.8 ms
brotli -11~14 KB850 ms0.9 ms
zstd -3~17 KB0.6 ms0.2 ms

Benchmarks are indicative (single-core, M2 Pro). Key insight: decompression is always fast regardless of compression level — the browser/client pays no meaningful cost. All CPU cost is on the server/sender side at compression time.

When NOT to use gzip

Content typeWhy skip gzipAction
JPEG / PNG / WebPAlready compressedSkip
MP4 / WebM / MP3Already compressedSkip
.zip / .gz / .brAlready compressedSkip
< 1 KB responseHeader overhead dominatesSkip / threshold
Server-Sent EventsStreaming: no Content-LengthUse carefully
WebSocketsUse permessage-deflate extensionPer-message
TLS + user secretsBREACH attack surfaceDisable / mask
nginx configuration
gzip on; — enable gzip
gzip_comp_level 6; — level 1–9
gzip_min_length 1000; — skip tiny responses
gzip_vary on; — adds Vary: Accept-Encoding
gzip_proxied any; — compress proxied responses
gzip_types text/plain text/css application/json application/javascript;
gzip_static on; — serve pre-compressed .gz files
CDN behaviour
CDNs (Cloudflare, CloudFront, Fastly) automatically gzip compressible content at the edge, even if your origin doesn’t. Cloudflare also applies brotli. Always set Vary: Accept-Encoding so the CDN stores separate cached copies for gzip and non-gzip clients. Without Vary, a non-gzip client might receive a gzip-encoded response they can’t decode — a common misconfiguration.
Brotli vs gzip choice
Use brotli for static assets that can be pre-compressed at build time (brotli -11 takes seconds per file but is done once). Use gzip -6 for dynamic responses compressed on the fly. Serve both if possible — check Accept-Encoding and pick br over gzip when available. Brotli yields ~15–20% better compression than gzip on typical web assets.
CPU & memory budget
gzip level 6 on a 100 KB JSON response costs ~2–3 ms CPU and ~256 KB memory (sliding window). At 1 000 req/s that’s 2–3 CPU-seconds per second — significant. Options: pre-compress cacheable responses (compute once, serve many), use a reverse proxy (nginx/Caddy) to offload compression from the app, or increase compression threshold so only large responses are compressed.
// Interactive Demo — LZ77 tokeniser → Huffman coder → gzip file layout, computed live in your browser
Enter text and press Compute to begin.