How to fix compression and caching headers
Your audit came back with a heading reading "Compression and caching headers" and a list of URLs under it, and the two halves of that heading sound like the same afternoon of server work. They are not. One is usually a single line of configuration and a real saving; the other is a smaller matter than its name suggests — and on some hosts the first is reported against a site already doing it correctly.
Docket's perf.compression check emits two findings, under
two ids, at two very different severities:
high perf.no_compression N pages are served without compression
low perf.no_cache_headers Pages are served with no caching headers
The gap between high and low is most of the answer. The check also
grades its own workload, and grades neither as an afternoon: the compression fix is filed as
trivial, which the engine defines as "edit one tag / one line of config, minutes",
and the caching fix as small, "a template change, under an hour".
What this cannot tell you, first
Docket reads the headers on the responses it was given. It does not measure how fast anything felt. There is no browser, no paint, no device and no network but ours. A finding here is a fact about the response that arrived at our crawler, and most of this page is about the distance between that and your site.
It cannot measure Core Web Vitals, and nothing that runs on one machine can. Largest Contentful Paint, Interaction to Next Paint and Cumulative Layout Shift come from real people on real connections. The check module says so in its own opening comment — that it measures the network and the document, not a rendered browser, and that "a number invented from static analysis would be worse than no number". Confirm the field values in Search Console.
It does not measure how much compression saved you, and
it only looked at HTML. Nothing is compressed twice and nothing is compared;
the check asks whether a Content-Encoding header was present and stops. Your
stylesheets, scripts and JSON endpoints were never fetched for it, so it has no opinion about
those — even though its own fix text asks you to turn compression on for them.
The false positive you should check before you change anything
This one matters, and it is ours rather than yours. Docket's crawler sends Accept-Encoding: gzip, deflate. It does not advertise
Brotli, because there is no Brotli decoder in the Python standard library and the engine is kept
dependency-free. The fetcher says so in its own comment: Docket never advertises
br, so Brotli cannot arrive from a specification-abiding server.
Now read that against the specification. Under
RFC 9110 section 12.5.3, the
Accept-Encoding request header is how a client states which content codings it
will accept, and a server that respects it will not send one that was not offered. So an origin
or CDN configured to serve Brotli and nothing else, behaving correctly, answers our crawler with
an uncompressed document and no Content-Encoding header. Docket then reports
perf.no_compression at high against a site whose real visitors have
had compressed HTML all along.
Check before you touch a configuration file, with the request a browser actually makes:
curl -sI -H 'Accept-Encoding: gzip, deflate, br' https://example.com/ | grep -i content-encoding
If that prints content-encoding: br and the same request without
br prints nothing, the finding is about our request rather than your server and
there is nothing to fix. The general form holds in both directions: a managed host or CDN can
compress for real visitors while answering an unfamiliar client differently, and the response we
got is the only thing this check ever saw.
What the check actually computes
Compression. For every page that returned a success status and parsed as
HTML, Docket asks two questions: is the HTML document bigger than the floor, and is a
Content-Encoding header present. That is the whole test:
uncompressed = [
p for p in ctx.ok_pages
if p.html_bytes > 20_000
and not p.headers.get("content-encoding")
]
Two things follow that people get wrong. First, any value satisfies it. The check
never reads what the header says, so it cannot distinguish gzip from Brotli from
identity. Second, the size floor is measured on the decompressed document:
html_bytes is documented in the model as the raw HTML byte length before any
transfer encoding, and is assigned after the fetcher has unpacked the body. It is the size of
your markup, not of what crossed the wire. A page under the floor is never reported however it
is served, because the saving on a small document does not justify a finding.
Caching. Here the shape is different, and this is the part most likely to
surprise you. A page counts as having no caching headers only when all of
Cache-Control, ETag and Last-Modified are absent. Any one
of the three is enough to clear it. And the finding is then gated on the site rather than the
page: it is only emitted when more than half of the crawled OK pages are bare. If a minority of
your pages have no caching headers, this check says nothing at all — and its title, "Pages are
served with no caching headers", is deliberately a statement about the site rather than a
count.
As with compression, presence is the test and the value is never read.
Cache-Control: no-store clears this check completely, because the check is asking
whether you said anything about caching, not whether it was wise.
Turning compression on
On nginx, the gzip module documentation is short and worth reading
in full. The directive defaults to off, and HTML is a special case: the documentation says
plainly that responses with the text/html type are always compressed, so
text/html does not belong in the type list.
gzip on;
gzip_types text/css application/javascript application/json image/svg+xml;
gzip_min_length 256;
On Apache, mod_deflate does the same job from the vhost or
.htaccess:
AddOutputFilterByType DEFLATE text/html text/css application/javascript application/json image/svg+xml
If you are on a managed platform and control neither file — the common case, and the reason
this page exists — the setting is in your host's dashboard rather than your repository. On a CDN
it is usually one toggle applied at the edge, and the CDN compresses on the way out whether or
not your origin did. That is why the fix is graded trivial: it is not a deploy.
Caching headers, and what to actually set
The reason this one is low rather than high is that a missing
caching header costs you nothing on a first visit, which is most search traffic. It costs you on
repeat visits and on re-crawls.
RFC 9111 section 3 sets out when a cache may store a
response at all, and RFC 9111 section 5.2 defines
Cache-Control as the field carrying directives to caches along the
request/response chain. The other two headers the check looks for are validators rather than
policy: RFC 9110 section 8.8.3 defines
ETag, RFC 9110 section 8.8.2
defines Last-Modified, and either lets a client ask "has this changed" and be told
no — the conditional-request mechanism in
RFC 9111 section 4.3. That is the part crawlers use,
and why a validator alone satisfies the check.
For HTML that changes when you publish, the check's own suggested value is conservative and sensible:
Cache-Control: public, max-age=0, must-revalidate
That does not mean "do not cache". It means the cache may keep a copy and must check with
you before reusing it — which, with an ETag alongside it, turns most repeat fetches
into a short response with no body. For fingerprinted assets, a long max-age with
immutable is the usual pairing. Those are not what this check looked at, but they
are where the saving is.
What this check is not
Header findings attract everything else that is slow, so the boundaries are worth stating. Docket's speed lane runs this check alongside separate ones with their own ids, their own thresholds and their own findings:
perf.page_weight— the size of the HTML document itself, decompressed. Samehtml_bytesfield, different question: compression is about how it was sent, weight is about how much of it there is.perf.render_blocking— stylesheets and scripts that block the first paint. Counted from the markup, and reported per page.perf.ttfb— time to first byte, taken from the fetch and reported against the median across the site.perf.modern_images— images still served as JPEG or PNG where a modern format would be smaller.
None of those is fixed by a compression header and none of them fixes one. The full list of what the engine looks at, lane by lane, is on what Docket checks. The lane's other markup-level finding is written up at how to fix layout shift, which carries the same limit from the other side: Docket infers layout-shift risk from markup and cannot measure the metric.
The closest relative outside this lane is response headers of a different kind. Missing security headers are set in the same file, at the same edge, by the same person — and this site fails two of those checks itself, because its host cannot set response headers at all. If you are opening that file anyway, do both.
So which is worth an afternoon
Neither, on the check's own grading, and that is the useful answer. Confirm the compression
finding with the curl above, because a Brotli-only host produces it falsely. If it
holds, turn compression on: one line, and the largest single win this check can point at. Then
set a Cache-Control value and make sure a validator is present — worth doing, not
urgent. Spend the rest of the afternoon on
something else on the list.
Every external reading on this page was taken on 15 September 2026: RFC 9110, HTTP Semantics and RFC 9111, HTTP Caching at rfc-editor.org, the gzip module at nginx.org, and mod_deflate at httpd.apache.org.
Common questions
Does Docket check for Brotli?
No, and this is the check's most important limit. Docket's crawler sends Accept-Encoding: gzip, deflate and never advertises br, because there is no Brotli decoder in the Python standard library. A server that only offers Brotli and obeys RFC 9110 will send our crawler an uncompressed document, so perf.no_compression can fire on a site that compresses perfectly for real browsers. Confirm with curl -sI -H 'Accept-Encoding: gzip, deflate, br' before changing anything.
What counts as compressed?
The presence of a Content-Encoding header on the response, whatever its value. The check never reads the algorithm, never compares sizes and makes no claim about how much was saved. It is a presence test, not a measurement.
Why does the compression finding only cover some pages?
There is a size floor, and it is measured on the decompressed HTML rather than on what crossed the wire. Pages whose markup is smaller than the floor are never reported however they are served, because the saving on a small document does not justify a finding.
What counts as a caching header?
Any one of Cache-Control, ETag or Last-Modified. A page only counts as bare when all three are absent, and the finding is only raised when more than half the crawled pages are bare — so it is a statement about the site, not a count of pages. The value is never read, so Cache-Control: no-store clears the check as thoroughly as a good policy would.
Will fixing this improve my Core Web Vitals score?
It may improve the experience, but Docket cannot tell you, and neither can any tool running on one machine. LCP, INP and CLS come from real visitors on real connections. Docket measures the causes; the field values belong in Search Console.
Does compression affect rankings directly?
Not as a header. Nothing in the check claims a ranking effect and this page will not invent one. Smaller responses arrive faster, which is worth having on its own terms.