JSON Compression: gzip, Brotli, zstd, MessagePack & CBOR

Last updated:

JSON text compresses extremely well because of its repetitive structure — gzip reduces typical JSON by 60–80%, Brotli by 70–85%, and Zstandard (zstd) by 70–88% while decompressing 5× faster than gzip. Enable HTTP compression by setting Content-Encoding: gzip (or br for Brotli) on the server — Express does this with the compression middleware; nginx with gzip on; gzip_types application/json. Brotli level 4 provides ~8% better compression than gzip level 6 with similar CPU cost. This guide covers HTTP Content-Encoding setup (Express, nginx), gzip vs Brotli vs zstd benchmarks, JSON key shortening techniques, MessagePack binary encoding (30–50% smaller than JSON), CBOR for embedded systems, and when binary formats beat compression.

HTTP Content-Encoding: gzip, Brotli, and zstd

HTTP compression is negotiated between client and server using two headers: the client sends Accept-Encoding: gzip, br, zstd listing supported algorithms, and the server responds with Content-Encoding: gzip (or br or zstd) indicating which algorithm was applied. The server must also set Vary: Accept-Encoding so intermediate caches (CDNs, proxies) store separate compressed copies per encoding, not a single copy that may be served to clients expecting a different encoding.

All modern browsers support gzip and Brotli. Zstandard (zstd) browser support landed in Chrome 123, Firefox 126, and Safari 18 (2024). For server-to-server API calls, all three are available through the Node.js built-in zlib module and most HTTP client libraries. gzip uses DEFLATE compression (LZ77 + Huffman coding). Brotli uses a combination of LZ77, Huffman coding, and a static dictionary of common web content tokens — the dictionary gives Brotli a structural advantage on text formats like JSON, HTML, and CSS. Zstandard uses ANS entropy coding and achieves higher compression speed than both at comparable ratios.

# Client request headers
GET /api/users HTTP/1.1
Accept-Encoding: gzip, deflate, br, zstd

# Server response headers (gzip)
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Content-Encoding: gzip
Vary: Accept-Encoding
# Content-Length is omitted — size changes after compression

# Server response headers (Brotli)
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Content-Encoding: br
Vary: Accept-Encoding

# curl — request Brotli, decompress automatically
curl --compressed -H "Accept-Encoding: br" https://api.example.com/data

# curl — inspect compression without decompressing
curl -v --compressed https://api.example.com/data 2>&1 | grep "Content-Encoding"

Never set Content-Length on compressed responses — the header must reflect the compressed byte count, but most frameworks omit it entirely and use chunked transfer encoding instead. Setting the wrong Content-Length causes clients to truncate or hang waiting for more data. The Vary: Accept-Encoding header is critical for correctness: without it, a CDN may cache the gzip version and serve it to a client that sent Accept-Encoding: identity (no compression), causing a corrupted response.

Express and nginx Compression Setup

Express does not compress responses by default. The compression npm package adds a middleware that negotiates gzip or deflate based on Accept-Encoding and compresses responses larger than 1 KB. For Brotli support in Express, use shrink-ray-current instead, which adds br negotiation. In nginx, gzip on enables gzip for configured MIME types; Brotli requires the separately compiled ngx_brotli module.

// ── Express: gzip with compression middleware ──────────────────────
npm install compression
npm install --save-dev @types/compression

import express from 'express'
import compression from 'compression'

const app = express()

// Apply compression before all routes
app.use(compression({
  threshold: 1024,      // only compress responses > 1 KB
  level: 6,            // gzip compression level (1=fast, 9=best, 6=default)
  filter: (req, res) => {
    // Don't compress if client sends Cache-Control: no-transform
    if (req.headers['cache-control']?.includes('no-transform')) return false
    // Use default filter for everything else (skips already-compressed types)
    return compression.filter(req, res)
  },
}))

app.get('/api/users', (req, res) => {
  res.json(largeUserArray)  // automatically gzip-compressed
})

// ── Express: Brotli with shrink-ray-current ────────────────────────
npm install shrink-ray-current

import shrinkRay from 'shrink-ray-current'
app.use(shrinkRay())  // negotiates br, gzip, or deflate automatically
# nginx.conf — gzip
http {
  gzip              on;
  gzip_comp_level   6;       # 1-9; 6 balances speed and ratio
  gzip_min_length   1024;    # don't compress responses < 1 KB
  gzip_vary         on;      # add Vary: Accept-Encoding header
  gzip_proxied      any;     # compress for all proxy requests
  gzip_types
    application/json
    application/javascript
    text/plain
    text/css
    text/xml
    application/xml;
}

# nginx: Brotli (requires ngx_brotli module)
# Ubuntu: apt install libnginx-mod-brotli
http {
  brotli            on;
  brotli_comp_level 4;       # 0-11; 4 is fast with good ratio
  brotli_static     on;      # serve pre-compressed .br files
  brotli_types
    application/json
    application/javascript
    text/plain
    text/css;
}

# Pre-compress static JSON files offline (Brotli level 11)
brotli --best -k api-response.json      # produces api-response.json.br
gzip -k -9 api-response.json           # produces api-response.json.gz

Place app.use(compression()) as early as possible in the Express middleware chain — before route handlers and other middleware that set response bodies. If you place it after a route handler, that handler has already sent the response and the compression middleware never runs. For static JSON files served from a CDN, pre-compress at Brotli level 11 and gzip level 9 offline (slow compression, done once) and serve the pre-compressed files — this decouples compression CPU cost from request latency entirely.

gzip vs Brotli vs zstd: Benchmarks and Trade-offs

Compression algorithms differ across three axes: compression ratio (smaller output is better), compression speed (CPU time to compress — matters for real-time server responses), and decompression speed (CPU time to decompress — matters for client-side performance). For JSON specifically, the repetitive key names and structural tokens make all three algorithms very effective, but the ratios diverge at higher compression levels.

# Benchmark comparison on a 500 KB JSON API response
# (typical e-commerce product catalog with nested objects)

Algorithm       Level   Compressed   Ratio   Compress    Decompress
─────────────────────────────────────────────────────────────────────
uncompressed    —       500 KB       1.00×   —           —
gzip            6       102 KB       4.9×    12 ms       4 ms
gzip            9       98 KB        5.1×    38 ms       4 ms
Brotli          4       95 KB        5.3×    14 ms       2 ms  ← sweet spot
Brotli          6       91 KB        5.5×    28 ms       2 ms
Brotli          11      84 KB        5.9×    2,400 ms    2 ms  ← offline only
zstd            3       97 KB        5.1×    3 ms        1 ms  ← fastest
zstd            19      85 KB        5.9×    420 ms      1 ms

# Node.js: zstd (via fzstd)
npm install fzstd

import { compress, decompress } from 'fzstd'
const jsonBuffer = Buffer.from(JSON.stringify(data))
const compressed = compress(jsonBuffer, { level: 3 })   // fast, real-time
const original   = decompress(compressed)
console.log(JSON.parse(Buffer.from(original).toString()))

# Node.js built-in: gzip and Brotli
import { promisify } from 'util'
import { gzip, gunzip, brotliCompress, brotliDecompress,
         constants } from 'zlib'

const gzipAsync   = promisify(gzip)
const brotliAsync = promisify(brotliCompress)

const jsonStr = JSON.stringify(data)

// gzip (default level 6)
const gzipped = await gzipAsync(jsonStr)

// Brotli level 4 (real-time)
const brotlied = await brotliAsync(jsonStr, {
  params: { [constants.BROTLI_PARAM_QUALITY]: 4 }
})

The practical recommendation: use Brotli level 4 for real-time API responses (best ratio at acceptable CPU cost), gzip level 6 as the universal fallback, and pre-compress static assets at Brotli level 11 offline. Zstandard at level 3 is the best choice when decompression speed on low-power clients is critical — it decompresses ~3× faster than gzip and ~1.5× faster than Brotli. For server-to-server JSON APIs where both sides support zstd, it offers the best compression-speed trade-off at low levels. See JSON performance optimization for additional payload reduction strategies.

JSON Key Shortening: Manual and Automated

JSON key shortening reduces payload size before compression by replacing verbose key names with shorter abbreviations. While compression handles key repetition well (gzip stores "description" once and references it), shorter keys also reduce the uncompressed size for clients that do not support compression, reduce the compression dictionary size, and improve readability in binary formats that preserve key names. Key shortening is most effective for large arrays of homogeneous objects where the same keys repeat thousands of times.

// ── Manual key shortening ──────────────────────────────────────────
// Before: verbose keys (common in ORM/database output)
const verbose = [
  { "user_identifier": 1, "display_name": "Alice", "email_address": "alice@example.com",
    "account_created_at": "2026-01-01", "is_account_active": true },
  { "user_identifier": 2, "display_name": "Bob", "email_address": "bob@example.com",
    "account_created_at": "2026-01-02", "is_account_active": false },
]
// JSON.stringify(verbose).length = 278 bytes

// After: shortened keys (define a schema separately)
const schema = {
  uid: "user_identifier", n: "display_name",
  e: "email_address", ca: "account_created_at", a: "is_account_active"
}
const compact = [
  { uid: 1, n: "Alice", e: "alice@example.com", ca: "2026-01-01", a: true },
  { uid: 2, n: "Bob",   e: "bob@example.com",   ca: "2026-01-02", a: false },
]
// JSON.stringify(compact).length = 128 bytes — 54% smaller before compression

// ── Automated key shortening with a codec ─────────────────────────
function encodeWithSchema(
  data: Record<string, unknown>[],
  schema: Record<string, string>  // short → long
): Record<string, unknown>[] {
  const reverse = Object.fromEntries(Object.entries(schema).map(([k, v]) => [v, k]))
  return data.map(obj =>
    Object.fromEntries(
      Object.entries(obj).map(([k, v]) => [reverse[k] ?? k, v])
    )
  )
}

function decodeWithSchema(
  data: Record<string, unknown>[],
  schema: Record<string, string>  // short → long
): Record<string, unknown>[] {
  return data.map(obj =>
    Object.fromEntries(
      Object.entries(obj).map(([k, v]) => [schema[k] ?? k, v])
    )
  )
}

// ── Column-oriented JSON (for arrays of objects) ───────────────────
// Instead of row-per-object, send column arrays — better gzip ratio

// Row-oriented (standard)
const rows = [
  { id: 1, name: "Alice", score: 95 },
  { id: 2, name: "Bob",   score: 87 },
  { id: 3, name: "Carol", score: 92 },
]

// Column-oriented (better for large datasets)
const columns = {
  id:    [1, 2, 3],
  name:  ["Alice", "Bob", "Carol"],
  score: [95, 87, 92],
}
// column arrays compress better — identical numbers pack tighter
// column "score" [95,87,92] gzips better than score:95, score:87, score:92 repeated

Column-oriented JSON is particularly effective for time-series data, analytics responses, and large tabular datasets. Instead of [{"ts":1,"v":1.2},{"ts":2,"v":1.3}], send {"ts":[1,2],"v":[1.2,1.3]} — the repeated key names disappear entirely, and the numeric arrays compress significantly better than interleaved key-value pairs. Libraries like Apache Arrow provide a standardized columnar format for this pattern. For the full picture, see JSON best practices.

MessagePack: Binary JSON Without Compression

MessagePack encodes the same data model as JSON — objects (maps), arrays, strings, integers, floats, booleans, and null — in a compact binary format. Each value is prefixed with a type byte that encodes the type and, for strings and arrays, the length. This eliminates all the overhead of JSON's text representation: no quotes around keys, no colons, no commas, no curly braces. A 5-field object that takes 80 bytes as JSON typically takes 45–55 bytes as MessagePack.

// ── MessagePack in Node.js ─────────────────────────────────────────
npm install @msgpack/msgpack

import { encode, decode } from '@msgpack/msgpack'

const data = {
  id: 42,
  name: "Alice",
  scores: [95, 87, 92],
  active: true,
}

// JSON baseline
const jsonBytes = Buffer.from(JSON.stringify(data))
console.log('JSON size:', jsonBytes.length)           // ~60 bytes

// MessagePack encoding
const packed = encode(data)
console.log('MessagePack size:', packed.byteLength)  // ~35 bytes (~42% smaller)

// Decoding
const unpacked = decode(packed)
console.log(unpacked.name)  // "Alice"

// ── MessagePack HTTP API (Express) ────────────────────────────────
import { encode, decode } from '@msgpack/msgpack'

app.get('/api/users', (req, res) => {
  const accepts = req.headers['accept']
  if (accepts?.includes('application/msgpack')) {
    res.set('Content-Type', 'application/msgpack')
    res.send(Buffer.from(encode(users)))
  } else {
    res.json(users)  // fallback to JSON
  }
})

app.post('/api/users', express.raw({ type: 'application/msgpack' }), (req, res) => {
  const body = decode(req.body)  // decode msgpack body
  // body has the same structure as the JSON equivalent
  res.json({ created: true, id: body.id })
})

// ── MessagePack + gzip: diminishing returns ───────────────────────
import { promisify } from 'util'
import { gzip } from 'zlib'
const gzipAsync = promisify(gzip)

const jsonStr = JSON.stringify(largeDataset)
const msgpackBuf = encode(largeDataset)

const jsonGzipped    = await gzipAsync(jsonStr)
const msgpackGzipped = await gzipAsync(msgpackBuf)

// Example results for a 500 KB dataset:
// JSON uncompressed:        500 KB
// MessagePack uncompressed: 280 KB  (-44%)
// JSON + gzip:              95 KB   (-81%)
// MessagePack + gzip:       80 KB   (-84%)  — only ~16% better than JSON+gzip
// Conclusion: if gzip is available, msgpack's advantage shrinks dramatically

MessagePack shines in scenarios where HTTP compression is not available or too costly: WebSocket messages (where per-message compression has connection state overhead), mobile apps with custom binary protocols, high-frequency real-time data streams, and embedded systems with limited CPU. For standard REST APIs over HTTP/2 where gzip or Brotli is already applied, the incremental benefit of MessagePack over JSON shrinks to 10–20% — weigh this against the added complexity of binary negotiation and the loss of curl/browser devtools debuggability. See JSON streaming for high-throughput real-time patterns.

CBOR: Concise Binary Object Representation

CBOR (RFC 8949) is an IETF-standardized binary format designed as a strict superset of JSON's data model. It shares MessagePack's approach of type-prefixed binary encoding but extends the type system with features critical for embedded and security applications: native byte strings, tagged types (timestamps, big integers, URIs, MIME types), indefinite-length encoding for streaming without knowing the length upfront, and 64-bit integer support without precision loss.

// ── CBOR in Node.js ───────────────────────────────────────────────
npm install cbor

import cbor from 'cbor'

const data = {
  id: 42,
  name: "Alice",
  timestamp: new Date('2026-05-20T00:00:00Z'),  // CBOR tag 1: epoch-based datetime
  binary: Buffer.from([0x01, 0x02, 0x03]),       // CBOR byte string (major type 2)
  bignum: 9007199254740993n,                      // CBOR tag 2: bignum
}

// Encoding
const encoded = cbor.encode(data)
console.log('CBOR size:', encoded.length)  // ~45 bytes for simple objects

// Decoding
const decoded = cbor.decodeFirstSync(encoded)
console.log(decoded.timestamp instanceof Date)  // true — type preserved!
console.log(decoded.binary instanceof Buffer)   // true — binary preserved!

// ── CBOR use cases: WebAuthn / CTAP2 ──────────────────────────────
// WebAuthn authenticator data is CBOR-encoded
// The attestation object from navigator.credentials.create() is CBOR
import cbor from 'cbor'

// Parse WebAuthn attestation response
const attestationBuffer = Buffer.from(attestationResponse.response.attestationObject)
const attestation = cbor.decodeFirstSync(attestationBuffer)
const { fmt, attStmt, authData } = attestation
// fmt: "packed" | "fido-u2f" | "none" | "android-key" | "tpm"

// ── CBOR vs MessagePack comparison ───────────────────────────────
// MessagePack: simpler spec, faster implementations, wider language support
// CBOR: IETF standard (RFC 8949), richer types, mandatory in WebAuthn/CoAP/COSE

// Size comparison on the same data object:
// JSON:        85 bytes
// MessagePack: 52 bytes  (-39%)
// CBOR:        54 bytes  (-36%)  — CBOR slightly larger due to richer type tags

CBOR is mandatory in several IETF-standardized protocols: CoAP (Constrained Application Protocol, RFC 7252) uses CBOR for IoT device payloads; COSE (CBOR Object Signing and Encryption, RFC 8152) uses CBOR for signed and encrypted data; CTAP2 (Client to Authenticator Protocol 2) uses CBOR for WebAuthn hardware key communication. If you are building IoT firmware, hardware security modules, or WebAuthn relying parties, CBOR is the correct choice — not a preference but a protocol requirement. For general-purpose binary JSON without these constraints, MessagePack has broader library support and simpler tooling.

When to Use Binary Formats vs Compressed JSON

The choice between binary formats (MessagePack, CBOR) and HTTP-compressed JSON depends on four factors: the availability of HTTP compression, the debuggability requirements of the API, the ecosystem support on client devices, and the presence of binary data (byte arrays, typed numbers) in the payload. Compressed JSON is the default choice for public APIs and browser clients; binary formats are appropriate for specific high-performance or constrained scenarios.

// Decision matrix: binary format vs compressed JSON

const scenarios = {
  // Use compressed JSON (gzip/Brotli + JSON)
  publicRestApi: {
    reason: "Universal browser/curl compatibility, human-readable in devtools",
    format: "JSON + Brotli",
  },
  graphqlApi: {
    reason: "Ecosystem tooling (Apollo, GraphiQL) expects JSON",
    format: "JSON + gzip",
  },
  webhooks: {
    reason: "Receivers expect JSON; cannot control client decompression",
    format: "JSON (compression optional)",
  },

  // Use MessagePack
  websocketRealtime: {
    reason: "Per-message compression has state overhead; binary is simpler",
    format: "MessagePack",
    saving: "30-50% smaller messages, lower latency",
  },
  mobileHighFrequency: {
    reason: "Reduce battery cost of CPU-intensive decompression on mobile",
    format: "MessagePack or zstd",
  },
  serverToServer: {
    reason: "Both sides under your control; no browser debuggability needed",
    format: "MessagePack + zstd",
    saving: "Up to 88% vs uncompressed JSON",
  },

  // Use CBOR
  iotEmbedded: {
    reason: "CoAP protocol requires CBOR; devices lack HTTP compression",
    format: "CBOR",
  },
  webauthn: {
    reason: "CTAP2 protocol mandates CBOR for authenticator communication",
    format: "CBOR (mandatory)",
  },
  binaryPayloads: {
    reason: "Avoid base64 encoding for binary data — CBOR byte strings are native",
    format: "CBOR",
    saving: "33% vs base64-encoded binary in JSON",
  },
}

// ── Hybrid approach: JSON API with MessagePack optimization ───────
// Serve both formats from the same endpoint using content negotiation
app.get('/api/data', async (req, res) => {
  const data = await fetchData()
  const accept = req.headers['accept'] ?? ''

  if (accept.includes('application/msgpack')) {
    // Binary client (mobile app, internal service)
    res.set('Content-Type', 'application/msgpack')
    res.send(Buffer.from(encode(data)))
  } else {
    // Standard JSON client (browser, curl)
    // Express compression middleware handles gzip/Brotli automatically
    res.json(data)
  }
})

The most impactful optimization for most APIs is simply enabling HTTP compression (gzip or Brotli) — this alone reduces JSON payload size by 60–85% with zero changes to the API contract, zero client-side library requirements, and full debuggability. Binary formats provide meaningful additional savings only in scenarios where HTTP-layer compression is unavailable or where the overhead of compression/decompression itself is a bottleneck. For APIs that already use Brotli compression, switching to MessagePack yields only ~15% additional savings while sacrificing human readability. See Express.js JSON API for complete production API setup and JSON streaming for large payload delivery patterns.

Key Terms

Content-Encoding
An HTTP response header that specifies the encoding transformations applied to the response body, such as gzip, br (Brotli), zstd, or deflate. Distinct from Transfer-Encoding, which applies to the transport layer (e.g., chunked). The client declares supported encodings in the Accept-Encoding request header; the server selects the best match and sets Content-Encoding accordingly. The server must also respond with Vary: Accept-Encoding so that caches (CDNs, proxies) store separate compressed copies per encoding. Content-Encoding is applied after the response body is fully formed — it is transparent to the application code and handled by the web server or middleware layer.
Brotli
A lossless data compression algorithm developed by Google and standardized in RFC 7932. Brotli uses a combination of LZ77 backward references, Huffman coding, and a pre-defined static dictionary of common web tokens (HTML tags, CSS properties, JavaScript keywords, and common JSON patterns). The static dictionary gives Brotli a compression advantage over gzip on text formats like JSON, HTML, and CSS. Brotli decompresses at ~600 MB/s, significantly faster than gzip's ~250 MB/s. HTTP Content-Encoding identifier: br. Supported by all modern browsers. Higher Brotli levels (9–11) are too slow for real-time compression — use level 4–6 dynamically and pre-compress static assets at level 11.
gzip
A file format and compression algorithm based on the DEFLATE algorithm (LZ77 + Huffman coding), standardized in RFC 1952. gzip is the most universally supported HTTP compression method — all browsers, HTTP clients, and web servers support it. At level 6 (the default), gzip compresses JSON by 60–80%. HTTP Content-Encoding identifier: gzip. The deflateidentifier refers to raw DEFLATE data (confusingly, not a gzip wrapper), and is less commonly used. gzip adds a 10-byte header and 8-byte footer to each compressed stream. For JSON APIs, gzip level 6 is the standard production setting; level 9 adds marginal compression benefit at 3× higher CPU cost.
Zstandard (zstd)
A fast lossless compression algorithm developed by Facebook (now Meta) and standardized in RFC 8878. Zstandard uses ANS (Asymmetric Numeral Systems) entropy coding and achieves compression ratios comparable to Brotli while compressing 5–10× faster than gzip at equivalent ratios and decompressing at ~1,700 MB/s (the fastest of the three). HTTP Content-Encoding identifier: zstd. Browser support arrived in 2024 (Chrome 123, Firefox 126, Safari 18). Zstd supports custom dictionaries trained on domain-specific data — a dictionary trained on your API's JSON structure can improve compression ratio by an additional 10–20% on small responses where general-purpose compression is less effective. Widely used in databases (Facebook's RocksDB), filesystems (ZFS), and container registries (OCI image layers).
MessagePack
A binary serialization format that encodes JSON-compatible data structures (maps, arrays, strings, integers, floats, booleans, null) in a compact binary representation. Each value is prefixed with a single byte that encodes the type and, for variable-length types, the length — eliminating all of JSON's text overhead (quotes, colons, commas, braces). MessagePack produces output 30–50% smaller than JSON without compression. Not human-readable. Content-Type for HTTP: application/msgpack. Popular implementations: @msgpack/msgpack (JavaScript), msgpack (Python, Ruby, Go). Widely used in Redis (for cached values), WebSocket APIs, and mobile app binary protocols. When gzip is applied to both, MessagePack retains only a ~10–20% size advantage over JSON.
CBOR
Concise Binary Object Representation — an IETF-standardized binary data format (RFC 8949) that is a strict superset of JSON's data model. CBOR extends MessagePack's approach with a richer type system: major types cover unsigned integers, negative integers, byte strings, text strings, arrays, maps, tagged items, and simple values (true, false, null, float). Tagged items (major type 6) allow CBOR to represent typed values that JSON cannot: timestamps (tag 1), big integers (tags 2–3), URIs (tag 32), MIME messages (tag 36), and more. CBOR is mandatory in WebAuthn (CTAP2), IoT protocols (CoAP, RFC 7252), and security standards (COSE, RFC 8152). CBOR and MessagePack produce similar output sizes; CBOR is slightly larger due to richer type tagging but provides better interoperability through IETF standardization.

FAQ

How much does gzip compress JSON?

gzip reduces JSON payload size by 60–80% in typical cases. A 100 KB JSON response commonly compresses to 15–25 KB with gzip level 6. JSON compresses so well because it contains highly repetitive patterns: the same key names appear in every object of an array, and structural tokens ({, }, [, ], :, ,) repeat throughout. Compression ratio improves with larger payloads — a 10 KB JSON object may compress to 3 KB (70%) while a 1 MB JSON array may compress to 80 KB (92%). Small payloads under 1 KB compress poorly or may expand due to compression header overhead. Use gzip level 6 for real-time responses and level 9 only for offline pre-compression of static assets.

Should I use gzip or Brotli for JSON compression?

Use Brotli when the client supports it (virtually all modern browsers send Accept-Encoding: br), and fall back to gzip for compatibility. Brotli level 4 provides ~8% better compression than gzip level 6 at similar CPU cost, and decompresses ~2.4× faster (~600 MB/s vs ~250 MB/s). For public REST APIs served to browsers, configure your server to prefer Brotli and fall back to gzip — nginx with ngx_brotli and Express with shrink-ray-currenthandle this automatically. For server-to-server APIs where both sides are under your control, consider Zstandard (zstd) at level 3 — it compresses as well as gzip at 5× the speed and decompresses the fastest of all three algorithms.

How do I enable JSON compression in Express?

Install and apply the compression middleware: npm install compression, then add app.use(compression()) before your routes. The middleware automatically negotiates gzip or deflate based on the client's Accept-Encoding header and compresses responses larger than 1 KB by default. Configure the threshold: compression({ threshold: 512 }) compresses responses over 512 bytes. For Brotli support, replace compression with shrink-ray-current: npm install shrink-ray-current, then app.use(shrinkRay()). Place the middleware as early as possible in the middleware chain — before route handlers, so all responses are compressed regardless of which route handles the request.

How do I enable Brotli in nginx?

Brotli requires the ngx_brotli module, which is not included in the default nginx build. On Ubuntu/Debian with nginx from the official nginx repository, install it with apt install libnginx-mod-brotli. Then add to your nginx.conf: brotli on; brotli_comp_level 4; brotli_types application/json text/plain text/css application/javascript;. For pre-compressed .br files, add brotli_static on; — nginx serves pre-compressed files when the client sends Accept-Encoding: br, eliminating real-time CPU cost. On Vercel, Netlify, and AWS CloudFront, Brotli is enabled by default with no configuration — CDNs handle compression transparently.

What is MessagePack and how does it compare to JSON?

MessagePack is a binary serialization format that encodes JSON-compatible data (objects, arrays, strings, numbers, booleans, null) in a compact binary representation using type-prefixed bytes instead of text characters. MessagePack is 30–50% smaller than equivalent JSON without any compression — a 100 KB JSON payload typically becomes 55–70 KB as MessagePack. It is not human-readable and requires a library on both client and server. When gzip is applied to both formats, MessagePack retains only a 10–20% size advantage over JSON because gzip already eliminates the repeated string patterns that MessagePack encodes more efficiently. MessagePack is best for WebSocket APIs, mobile binary protocols, and server-to-server communication where HTTP compression is unavailable or adds unacceptable overhead.

What is CBOR?

CBOR (Concise Binary Object Representation) is an IETF-standardized binary data format (RFC 8949) that encodes JSON-compatible structures in binary with extended type support: native byte strings, typed tags for timestamps and big integers, and indefinite-length encoding for streaming. CBOR produces 20–40% smaller output than JSON text. It is the mandatory serialization format in WebAuthn (CTAP2 protocol for hardware authenticators), CoAP (Constrained Application Protocol for IoT), and COSE (CBOR Object Signing and Encryption). Use CBOR when building IoT firmware, WebAuthn relying parties, or systems that must comply with IETF protocols. For general-purpose binary JSON without protocol constraints, MessagePack has broader library support and simpler tooling.

How do I compress JSON in Node.js manually?

Use the built-in zlib module — no npm install needed. For gzip: const { promisify } = require('util'); const gzip = promisify(require('zlib').gzip); const compressed = await gzip(JSON.stringify(data));. For Brotli: use promisify(require('zlib').brotliCompress) with params: { [constants.BROTLI_PARAM_QUALITY]: 4 }. Decompress with promisify(zlib.gunzip) or promisify(zlib.brotliDecompress) then JSON.parse(result.toString()). For streaming large JSON files, pipe through zlib.createGzip() as a Transform stream. Node.js 18+ offers import { pipeline } from 'stream/promises' for cleaner stream composition.

Is it worth compressing small JSON payloads?

No — do not compress JSON responses smaller than 1 KB. Compression adds gzip header/footer overhead (18+ bytes), increases CPU time on both server and client, and can produce output larger than the original for small inputs. A 200-byte JSON object may compress to 250 bytes. The standard threshold across web servers and middleware is 1 KB: Express compression defaults to threshold: 1024, nginx uses gzip_min_length 1k, and most CDNs use similar minimums. For high-frequency health check endpoints, authentication token responses, or simple status APIs that return small JSON, disabling compression reduces server CPU load with no meaningful bandwidth impact. Only compress when the uncompressed response clearly exceeds 1 KB — a single user object with 5–10 fields rarely does.

Further reading and primary sources

  • RFC 7932: Brotli Compressed Data FormatIETF specification for the Brotli compression algorithm and HTTP Content-Encoding: br
  • MDN: Content-EncodingHTTP Content-Encoding header reference with gzip, br, zstd, and deflate encoding values
  • MessagePack SpecificationOfficial MessagePack format specification with type system and encoding rules
  • RFC 8949: CBORIETF specification for Concise Binary Object Representation (CBOR) — the successor to RFC 7049
  • npm: compressionExpress compression middleware documentation — options, threshold, filter, and level configuration