How to Compress API Functions with Brotli, Gzip, or LZ-String
Learn when to use Brotli, gzip, or LZ-String for API responses, with runnable Node.js, Express, cURL, Python, and JavaScript examples.

For normal HTTP API responses, use Brotli or gzip as HTTP content encodings. The client advertises supported encodings with Accept-Encoding; the server compresses the representation and identifies the result with Content-Encoding. Use LZ-String only when your API contract intentionally carries an application-level encoded string or byte representation. LZ-String is not a drop-in HTTP content encoding.
In Node.js, the built-in node:zlib module supports gzip and Brotli. Express applications can use the compression middleware. For LZ-String, both sides must agree on the exact output format and matching decompression method.
Choose the right compression layer
| Decision | Brotli | gzip | LZ-String |
|---|---|---|---|
| Layer | HTTP content encoding | HTTP content encoding | Application-level representation |
Negotiated with Accept-Encoding? |
Yes | Yes | No |
| Node.js implementation | zlib.createBrotliCompress() or Express middleware |
zlib.createGzip() or Express middleware |
lz-string paired methods |
| Typical use | HTTP responses when the client supports br |
Broad HTTP compatibility | Compact strings in a deliberately defined payload format |
| Main concern | Measure quality, CPU, and client support | Choose a level and measure CPU versus size | Format and decoder compatibility across languages |
Do not claim one is universally smallest or fastest. The primary documentation does not establish a directly comparable Brotli-versus-gzip-versus-LZ-String benchmark for API JSON. Measure representative payloads in your own runtime and traffic pattern.
How HTTP response compression works
- The client sends an
Accept-Encodingheader, such asbr, gzip. - The server selects an encoding it supports and that the client accepts.
- The server compresses the response bytes.
- The server sends
Content-Encoding: brorContent-Encoding: gzip. - If caches can store either compressed or uncompressed representation, the response should include
Vary: Accept-Encoding.
Never set Content-Encoding unless the bytes have actually been encoded. A mislabeled response causes clients to attempt the wrong decompressor and usually produces corrupted output or a decoding error.

Node.js: compress a response with Brotli or gzip
The following server chooses Brotli when the request advertises br, gzip when it advertises gzip, and otherwise sends plain JSON. It uses streaming APIs so large responses do not need to be assembled into one additional compressed buffer.
import http from 'node:http';
import { promisify } from 'node:util';
import { createGzip, createBrotliCompress, constants } from 'node:zlib';
const payload = JSON.stringify({
ok: true,
items: Array.from({ length: 1000 }, (_, id) => ({ id, name: `item-${id}` }))
});
const pipeline = promisify((...args) => import('node:stream').then(({ pipeline }) => pipeline(...args)));
function accepts(acceptEncoding, name) {
return acceptEncoding
.split(',')
.map(part => part.trim().split(';')[0].toLowerCase())
.includes(name);
}
const server = http.createServer(async (req, res) => {
const accepted = req.headers['accept-encoding'] || '';
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.setHeader('Vary', 'Accept-Encoding');
try {
if (accepts(accepted, 'br')) {
res.setHeader('Content-Encoding', 'br');
const compressor = createBrotliCompress({
params: { [constants.BROTLI_PARAM_QUALITY]: 5 }
});
await pipeline(ReadableFromString(payload), compressor, res);
return;
}
if (accepts(accepted, 'gzip')) {
res.setHeader('Content-Encoding', 'gzip');
await pipeline(ReadableFromString(payload), createGzip(), res);
return;
}
res.end(payload);
} catch (error) {
if (!res.headersSent) res.writeHead(500);
res.end('compression failed');
}
});
function ReadableFromString(value) {
const { Readable } = require('node:stream');
return Readable.from([value]);
}
server.listen(3000, () => console.log('http://localhost:3000'));
For production code, use the stream utilities from your deployed Node.js version and test the error path. The Node.js zlib documentation covers the current APIs, supported encodings, Brotli parameters, and decompression behavior: Node.js zlib documentation.
A simpler buffer-based example
Buffer APIs are convenient for small, already-materialized responses:
import { gzip, brotliCompress } from 'node:zlib';
import { promisify } from 'node:util';
const gzipAsync = promisify(gzip);
const brotliAsync = promisify(brotliCompress);
const body = Buffer.from(JSON.stringify({ message: 'hello' }));
const compressed = await brotliAsync(body);
console.log(compressed.length);
For repeated content, cache the compressed representation instead of recompressing identical bytes on every request. Node.js notes that zlib work can be expensive and that asynchronous operations use the internal threadpool. Large numbers of concurrent zlib objects can also increase memory pressure.
Express: enable compression middleware
Install the middleware:
npm install compression
Then place it before routes whose responses should be compressed:
import express from 'express';
import compression from 'compression';
const app = express();
app.use(compression({
// -1 uses the package's default gzip compromise.
level: -1,
// The documented default is 1 KB. Set your own policy explicitly if needed.
threshold: '1kb',
filter: (req, res) => {
if (req.headers['x-no-compression']) return false;
return compression.filter(req, res);
}
}));
app.get('/api/data', (req, res) => {
res.json({ items: Array.from({ length: 1000 }, (_, id) => ({ id })) });
});
app.listen(3000);
Express’s compression middleware documentation describes support for gzip, Brotli, and deflate. Its default filter checks whether the response content type is compressible, and its documented threshold is 1 KB. Threshold behavior is advisory when the body length is unknown by the time headers are committed.
Choosing Express settings
- Threshold: Skip tiny responses where headers cost more than compression saves. Remember that streaming responses may not have a known length.
- Gzip level: Higher levels can reduce size at the cost of more CPU and latency; lower levels favor speed. Express documents levels 0 through 9 and
-1as the default compromise. - Filter: Skip already-compressed formats, streaming endpoints with special requirements, or responses where compression is not useful.
- Brotli parameters: Treat quality settings as workload-specific. Node.js v26 documentation describes quality 6 as suitable for streaming and quality 11 as intended for offline or build-time compression; verify guidance against your deployed runtime.
Testing compressed responses with cURL, Python, and Node.js
cURL
curl --compressed -i http://localhost:3000/api/data
--compressed asks cURL to advertise supported encodings and decompress the response for display. To inspect headers while saving the decoded body:

curl --compressed -D headers.txt http://localhost:3000/api/data -o data.json
cat headers.txt
Python
import requests
response = requests.get('http://localhost:3000/api/data', timeout=30)
response.raise_for_status()
print(response.headers.get('Content-Encoding'))
print(response.json())
Most Python HTTP clients transparently decode gzip and Brotli when the relevant optional support is installed. If you need raw compressed bytes, disable automatic decoding in the specific client you use and document that behavior.
Node.js client
const response = await fetch('http://localhost:3000/api/data', {
headers: { 'Accept-Encoding': 'br, gzip' }
});
console.log(response.headers.get('content-encoding'));
const data = await response.json();
console.log(data.items.length);
LZ-String for an application-level contract
LZ-String is useful when the encoded value itself is part of your application protocol—for example, a compact state value in a URL or a text-only field. It does not participate in HTTP negotiation, and you should not label an LZ-String value as Content-Encoding: gzip or br.
npm install lz-string
import LZString from 'lz-string';
const source = JSON.stringify({ filters: ['open', 'recent'], page: 2 });
const base64 = LZString.compressToBase64(source);
const restoredBase64 = LZString.decompressFromBase64(base64);
const uriValue = LZString.compressToEncodedURIComponent(source);
const restoredUri = LZString.decompressFromEncodedURIComponent(uriValue);
const utf16 = LZString.compressToUTF16(source);
const restoredUtf16 = LZString.decompressFromUTF16(utf16);
const bytes = LZString.compressToUint8Array(source);
const restoredBytes = LZString.decompressFromUint8Array(bytes);
console.log(restoredBase64 === source);
console.log(restoredUri === source);
console.log(restoredUtf16 === source);
console.log(restoredBytes === source);
The project documents raw, Base64, URI-safe, UTF-16, and Uint8Array methods and their paired decompressors in its official repository. Raw compressed output is not safe for arbitrary text storage. For a multi-language API, specify the exact format, character or byte transport, package expectations, and test vectors. Ports maintained by other developers are separate implementations, so verify compatibility rather than assuming identical behavior.
Designing a reliable compression policy
- Define representations: Decide which media types are compressible and which formats are already compressed, such as JPEG, PNG, WebP, ZIP, or PDF.
- Negotiate correctly: Honor
Accept-Encoding, send the matchingContent-Encoding, and addVary: Accept-Encodingwhen caches may store alternate representations. - Set a minimum size: Use a threshold based on measured header overhead and payload sizes.
- Cache repeated output: Cache compressed bytes when the payload, encoding, and relevant headers are stable.
- Stream large bodies: Use streaming zlib APIs and a pipeline so backpressure and errors are handled.
- Measure under concurrency: Record response size, time to first byte, total latency, CPU, memory, runtime version, compression settings, and client mix.
- Validate every decoder: Include tests for empty bodies, Unicode, malformed data, truncated streams, and content types that should remain uncompressed.
Common errors and fixes
| Symptom | Likely cause | Fix |
|---|---|---|
| Browser reports decoding failure | Content-Encoding does not match actual bytes |
Set the header only after selecting and applying the compressor; verify with cURL. |
| Cached clients receive the wrong variant | Cache ignores Accept-Encoding |
Send Vary: Accept-Encoding and configure the intermediary cache. |
| Small responses are slower | Compression overhead exceeds the size saved | Raise the threshold or skip tiny responses. |
| CPU spikes under load | High compression quality or too many concurrent zlib jobs | Lower quality, limit concurrency, cache repeated results, and measure again. |
LZ-String returns null or corrupted text |
Wrong paired format or incompatible port | Use the matching decompressor and publish test vectors in the contract. |
| JSON is still large | Payload contains long keys, repeated metadata, or already-compressed values | Measure serialization and schema changes separately; do not expect compression to fix an oversized data model. |
| Streaming response is truncated | Pipeline or compressor errors are not propagated | Use a stream pipeline, listen for errors, and avoid writing a success status before compression can fail. |
Performance, reliability, and cost notes
Compression trades network transfer for compute and memory. Benchmark with production-shaped JSON, realistic response sizes, concurrency, and client distribution. Record the Node.js version and all quality or level parameters. A result measured on one payload cannot establish a universal winner.
Compression does not remove the need for timeouts, retries, cancellation, backpressure, and observability. Log the selected encoding, uncompressed and compressed byte counts where practical, compression duration, and failures. Keep an uncompressed path for clients that advertise no supported encoding.
If your API sends secrets alongside attacker-controlled values, review your application security rules before compressing them together. The referenced documentation does not establish a security guarantee for that scenario.
Or skip the browser setup
If your API workflow also needs website screenshots for documentation, visual regression, or agent workflows, ScreenshotNeo provides a single website screenshot request. It is separate from HTTP response compression, so you can keep your API compression policy while outsourcing browser capture.
cURL:
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
Python:
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
Node.js:
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
See the ScreenshotNeo API documentation for request options. Cookie and consent banners, newsletter popups, and chat widgets are removed before the shot. Bot checks, blank pages, failed loads, timeouts, and cache hits are not billed, and response headers identify the page verdict and billing result. An MCP server lets Claude, Cursor, and other MCP clients use take_screenshot, get_page_info, and capture_pdf. The Free plan includes 1,000 screenshots each month with no card; paid plans start at $5 for 3,000 shots. Create a free ScreenshotNeo account.
FAQ
Should every JSON API response be compressed?
No. Apply a measured threshold and skip tiny bodies or formats that are already compressed. Middleware filters are useful for this policy.
Is Brotli always better than gzip?
No universal result is established by the cited sources. Compare size, latency, CPU, and memory using your own payloads and traffic.
Can a browser automatically decode LZ-String?
No. Your application must call the matching LZ-String decompression method. HTTP clients automatically understand standard HTTP encodings such as gzip or Brotli when negotiated.
Do I need to compress request bodies too?
Only when both client and server explicitly support a request content encoding. This article focuses on response compression; document request behavior separately with the appropriate headers.
Where should compression happen?
It can happen in the Node.js application, Express middleware, or an upstream proxy. Choose one authoritative layer, confirm headers and cache behavior, and avoid accidentally compressing an already-compressed response twice.