Quick answer: Before enabling JSON API compression, test that headers match the bytes, negotiated variants stay separate, exactly the intended layers decode the body, uploads have decoded-size limits, compression fits your resource budget, and streaming still delivers records promptly. A smaller transfer is useful only when the complete request path remains correct.
Your API returns valid JSON locally. After a proxy change, one client reports unreadable characters while another works. The JSON did not necessarily change; the representation reaching each client did. These six checks turn that confusing symptom into a release checklist rather than a trial-and-error middleware change.
This guide covers HTTP content compression, not reducing fields or changing serialization formats. For broader size planning, start with JSON file and request size limits. Wikipedia's HTTP compression overview provides background terminology; the behavior below is supported by primary documentation and local executable fixtures.
1. Do the response headers describe the bytes actually sent?
Keep the decoded media type and the applied content coding separate. A compressed JSON response still uses application/json as its Content-Type, while Content-Encoding identifies gzip or another coding applied to its bytes. Verify the body against those headers and remove stale length metadata whenever a component transforms the representation being transmitted.
MDN documents Content-Encoding as a description of applied codings, not a promise to compress later. When Content-Length is present, it describes the encoded representation. Setting a gzip header on plain JSON is therefore a protocol bug, even if the text looks perfectly readable in an application log.
Use a harmless local fixture to establish the distinction:
import assert from 'node:assert/strict';
import { gzipSync, gunzipSync } from 'node:zlib';
const value = { items: [{ sku: 'A-100', quantity: 2 }] };
const plain = Buffer.from(JSON.stringify(value), 'utf8');
const encoded = gzipSync(plain);
const headers = {
'content-type': 'application/json',
'content-encoding': 'gzip',
'content-length': String(encoded.length)
};
assert.equal(Number(headers['content-length']), encoded.length);
assert.deepEqual(JSON.parse(gunzipSync(encoded).toString('utf8')), value);
assert.throws(() => JSON.parse(encoded.toString('utf8')));
These assertions demonstrate the byte boundary, not an HTTP server configuration. In a deployment test, capture the headers and body together at the same hop. Comparing origin headers with a browser's decoded preview can otherwise make a healthy response look inconsistent.
Give the transforming component responsibility for updating representation metadata. Do not hard-code a compressed length from a previous response, or retain a length calculated before a reverse proxy changed the body. The JSON Content-Type guide explains the separate media-type decision; compression adds another layer rather than replacing it.
2. Are negotiated compressed and uncompressed variants kept separate?
Test compression negotiation with explicit client preferences and confirm that caches select a compatible representation. When response coding depends on Accept-Encoding, preserve Vary: Accept-Encoding alongside other required Vary fields. Exercise both request orders through the real cache so a warm gzip response never becomes unreadable content for an identity-only client.
Accept-Encoding documentation explains coding preferences and quality values. Do not implement negotiation with a substring check that treats gzip;q=0 as permission. Use a tested HTTP-aware implementation and define what happens when the client rejects every coding you offer, including identity.
The cache dimension is distinct from the algorithm decision. MDN's Vary reference explains how named request headers affect response selection. Replacing an existing Vary value can discard another necessary dimension. Append or merge fields through the framework's supported API instead of overwriting blindly.
Build a small integration matrix:
| Client preference | What to inspect |
|---|---|
| gzip accepted | Correct coding header and a decodable body |
| identity requested | Uncompressed body when identity is selected |
| gzip explicitly refused | No gzip response |
| Both identity and offered coding refused | Documented negotiation failure |
Run the matrix first against the origin, then through the deployed cache. Repeat after warming the cache with the opposite variant. Compare decoded JSON values rather than assuming compressed byte streams must be identical.
Encoding variation does not authorize shared caching of private responses. User isolation, cache directives, validators, and invalidation remain separate concerns covered in the JSON API caching checklist. Treat a passing compression matrix as evidence about compatible representations, not proof that personalized data is safe to cache.
3. Does each client decode the response exactly where intended?
Identify whether the HTTP client returns encoded bytes or already decoded content before adding a decompression call. Let one designated layer remove each applied coding, then decode text and parse JSON. Test the actual library configuration because a response header can describe the original transfer even when the returned body has already been decoded.
The curl manual makes an important distinction: --compressed requests compression and automatically decodes the response, while saved headers are not rewritten. A later script that sees Content-Encoding in those saved headers must not conclude that the saved body still needs decoding.
Write down the representation at each handoff: origin output, proxy output, HTTP library result, and parser input. This is more useful than logging only a generic parsing exception. Keep logs free of credentials and personal records; lengths, coding names, request identifiers, and a synthetic fixture are usually enough to reproduce the boundary.
Use this second local demonstration to catch an accidental extra layer:
import assert from 'node:assert/strict';
import { gzipSync, gunzipSync } from 'node:zlib';
const plain = Buffer.from('{"ok":true}');
const once = gzipSync(plain);
const twice = gzipSync(once);
assert.deepEqual(gunzipSync(once), plain);
assert.throws(() => gunzipSync(plain));
assert.deepEqual(gunzipSync(twice), once);
assert.throws(() => JSON.parse(gunzipSync(twice).toString('utf8')));
Legitimate multiple content codings are not the same thing as accidental double compression. HTTP Semantics, section 8.4 defines the coding list and application order; decoding reverses that order. The problem is a mismatch between the declared stack and the transformation actually performed.
After decoding, inspect a small redacted fixture with the JSON Viewer tree view. It helps locate fields and nested arrays. It does not inspect gzip transport, establish signature validity, or verify proxy configuration.
4. Are compressed uploads limited before and during decompression?
Set separate budgets for incoming compressed bytes and the decoded body, and enforce them while reading rather than after allocating the full result. Reject unsupported codings and malformed streams explicitly. A small transfer can expand substantially, so a Content-Length check alone cannot establish that parsing the decompressed JSON fits your service's memory budget.
Inbound compression is an independent feature from compressing responses. If your endpoint does not need compressed uploads, refusing them can be simpler than introducing a new resource boundary. If it does need them, document supported codings and ensure that a gateway has not already decoded the body before application middleware sees it.
A concrete recent example is Magpie issue 1223: on October 8, 2026, the maintainer reported a release addressing compressed bodies on additional gateway routes, decoded-byte limits, and unsupported-coding errors. That project-specific report illustrates why route coverage matters; it is not evidence that every gateway has the same defect.
Express body-parser documentation exposes inflation controls and a body limit. Its current reading implementation passes the decompression stream to the bounded body reader. Verify your installed version and middleware order rather than assuming a wire-size rule is equivalent to a decoded-size rule.
Here is a bounded in-memory demonstration, not production upload middleware:
import assert from 'node:assert/strict';
import { gzipSync, gunzipSync } from 'node:zlib';
const fixture = gzipSync(Buffer.from(JSON.stringify({ note: 'x'.repeat(4096) })));
assert(fixture.length < 1024);
assert.throws(() => gunzipSync(fixture, { maxOutputLength: 1024 }));
const decoded = gunzipSync(fixture, { maxOutputLength: 8192 });
assert.equal(JSON.parse(decoded.toString('utf8')).note.length, 4096);
assert.throws(() => gunzipSync(fixture.subarray(0, 10)));
Node's zlib options define maxOutputLength for convenience methods. This fixture checks output size and truncation; it does not implement streaming admission control, a deadline, an expansion-ratio policy, or a concurrency budget. A production test should show rejection before expensive business work and confirm that failed uploads release their resources.
5. Does compression improve the endpoint within its resource budget?
Measure compression with representative payloads and expected concurrency instead of choosing the highest level or enabling every response. Compare transferred bytes, CPU time, memory, and end-to-end latency against an uncompressed baseline. Keep small-response thresholds and sensitive-response exclusions explicit so bandwidth savings do not conceal slower requests or an unacceptable exposure of secret-dependent lengths.
Amazon API Gateway's compression guidance warns that small payloads can grow and that compression and decompression add processing work. That is a reason to benchmark your endpoint, not a universal threshold or a promise about savings. A repetitive inventory list and a tiny status object are different workloads.
Create a repeatable workload containing the shapes your application actually returns: a short error, an ordinary page, a large export, and strings that do not compress well. Record the test environment and client settings. Compare tail latency as well as averages, and distinguish network wait from serialization and compression time.
The synchronous examples in this article keep local assertions short. They are not a recommendation to compress substantial responses synchronously in a busy request handler. Choose the runtime or proxy mechanism deliberately, then measure it under concurrent traffic rather than extrapolating from a single local run.
Security can also justify exclusions. The NGINX gzip module documentation warns about BREACH exposure with encrypted compressed responses. Review whether attacker-controlled content and secrets share a response and whether length observations are possible. Compression is not automatically unsafe for every authenticated endpoint, but HTTPS alone does not settle that review.
Keep the release decision concrete: which routes qualify, who owns compression, what measurements justify the threshold, and how the feature can be disabled without changing the JSON contract.
6. Do streamed JSON records still reach clients promptly?
Measure time to the first usable record and the gaps between records after enabling compression on a streaming endpoint. Compression and intermediary buffering can delay delivery even when the final transfer shrinks. Preserve backpressure, cancellation, and error propagation, and verify an intentional flush policy through the deployed proxy rather than testing only the application process.
Express compression documentation explains buffering and its flush mechanism for server-sent events. Flushing more often trades compression efficiency for delivery latency. The correct policy depends on whether your consumer needs immediate events or is downloading a completed document; do not apply a single setting to both without measurement.
For a record stream, use a synthetic producer with deliberate pauses. Timestamp record creation and client availability, then compare compression enabled and disabled. Test through the same proxy path users take. Receiving all records eventually proves completeness, but does not prove that a progress indicator or event consumer remains responsive.
Compression boundaries are not JSON record boundaries. One decompressed chunk can contain part of a record or several records, and text decoding must also handle characters split across byte chunks. Keep framing and incremental text decoding separate from decompression. The NDJSON guide covers newline-delimited records; an ordinary JSON array is not automatically an independently parseable record stream.
Include cancellation, malformed compressed input, a slow reader, and an upstream disconnect in the deployment test. Confirm the producer stops when nobody is consuming its output, and do not convert a truncated response into a successful partial document merely to suppress errors.
Before release, retain evidence for all six checks: matching bytes and headers, compatible cache variants, decoder ownership, bounded uploads, measured resource costs, and timely streaming. That evidence is more useful than a compression toggle or an attractive transfer-size screenshot. The local fixtures above were executed with Node.js 24; live proxy behavior still requires an integration test in your own environment.