Three different problems

“The site is slow” usually means one of three things, and each has a different fix:

  1. The first byte takes long. The browser is waiting for the server to start answering. Compression and caching don’t help this for the HTML page itself.
  2. Too many bytes cross the wire. Text files go out uncompressed, so the download takes longer than it should.
  3. Files are downloaded again on every visit. Without the right caching headers, a returning visitor fetches the same CSS and JavaScript every time.

Measure before you change anything. Each has a ten-second check with curl.

Measure the first byte

curl -s -o /dev/null -w 'dns %{time_namelookup}  connect %{time_connect}  tls %{time_appconnect}  ttfb %{time_starttransfer}  total %{time_total}\n' https://example.com/
dns 0.012  connect 0.045  tls 0.112  ttfb 0.874  total 0.921

The numbers are seconds, counted cumulatively from the start of the request. Read them as differences:

  • DNS lookup: 12 ms.
  • TCP connect: 45 − 12 = 33 ms, which is roughly one round trip to the server.
  • TLS handshake: 112 − 45 = 67 ms.
  • Waiting for the first byte after the handshake: 874 − 112 = 762 ms. That’s the server working on the request (plus one round trip for the request to travel).

In this example the network is fine and the server is slow. If instead connect were 300 ms, the server is far away or the route is bad, and no amount of application tuning will fix it; a CDN or a closer region will.

Run it three or four times. The first request often hits cold caches (DNS, application, database) and is slower than the rest.

As a rough target, web.dev suggests a TTFB of 0.8 seconds or less for most sites, measured for real users. The HTTP check reports the time to the first response from DechoNet’s servers, including connection setup, and flags anything over one second. It’s a single sample from one location, so treat it as a smoke test and use curl from where your users are for the real measurement.

When the first byte is slow

Redirect chains. Each redirect is a full extra request. http://example.com → https://example.com → https://www.example.com → https://www.example.com/en/ costs three round trips before the real page starts. See every hop with:

curl -sIL http://example.com/ | grep -iE '^(HTTP|location)'

Point links, canonical tags and the first redirect straight at the final URL. The 301 vs 302 guide covers which redirect to use.

The HTML is generated on every request. A CMS page that runs dozens of database queries for an anonymous visitor is the most common cause of a slow first byte. Cache whole pages for logged-out visitors: a caching plugin, nginx fastcgi_cache or proxy_cache, or a CDN rule that caches HTML.

The application or database is slow. Find out which, using the time nginx waited on the backend:

log_format timing '$remote_addr "$request" $status rt=$request_time urt=$upstream_response_time';
access_log /var/log/nginx/timing.log timing;

If urt is close to rt, the application is slow, so look at its logs, slow query log and external API calls. If rt is much larger than urt, time is going to the client side: slow networks or large uploads.

Cold starts. Serverless functions and sleeping free-tier containers add a startup delay to the first request after a quiet period. It shows as one slow request followed by fast ones.

CDN misses. With a CDN in front, look at its cache status header:

curl -sI https://example.com/ | grep -iE '^(cf-cache-status|x-cache|age)'

On Cloudflare, DYNAMIC means the response wasn’t eligible for caching; Cloudflare doesn’t cache HTML by default. MISS means it was eligible but not in cache yet, HIT means it came from cache, and age is how many seconds it has been there.

Compression

How it’s negotiated

The browser lists the encodings it understands, and the server picks one:

Request:   Accept-Encoding: gzip, deflate, br, zstd
Response:  Content-Encoding: br
           Vary: Accept-Encoding

Browsers only advertise br over HTTPS. Recent Chrome and Firefox versions also advertise zstd; supporting it is optional. Vary: Accept-Encoding tells caches to keep the compressed and uncompressed versions apart. Without it, a shared cache can hand a Brotli body to a client that asked for none.

This has a consequence for testing: a server only compresses when the request asks for it. A tool or script that doesn’t send Accept-Encoding gets an uncompressed response and will report “no compression” for a site that compresses perfectly well for browsers. Check yourself before changing configuration.

Check it with curl

curl -sI -H 'Accept-Encoding: br' https://example.com/ | grep -iE '^(content-encoding|vary)'
curl -sI -H 'Accept-Encoding: gzip' https://example.com/ | grep -iE '^(content-encoding|vary)'

-I sends a HEAD request. If it shows no content-encoding, repeat with a real GET before concluding anything, because a few servers handle HEAD differently:

curl -s -o /dev/null -D - -H 'Accept-Encoding: br' https://example.com/ | grep -i '^content-encoding'

To see what it saves, compare bytes on the wire. curl doesn’t decompress unless you pass --compressed, so size_download here is the transferred size:

curl -s -o /dev/null -w '%{size_download}\n' https://example.com/assets/app.js
curl -s -o /dev/null -w '%{size_download}\n' -H 'Accept-Encoding: br' https://example.com/assets/app.js

Check the HTML, a CSS file, a JavaScript file and a JSON API response. They’re often served by different parts of the stack.

What to compress

Compress text: HTML, CSS, JavaScript, JSON, SVG, XML, plain text, and font formats that aren’t already compressed (TTF, OTF). Don’t bother with JPEG, PNG, WebP, AVIF, video, zip files or WOFF2 fonts; they’re compressed already and you only spend CPU. Very small responses, under about 1 KB, gain almost nothing.

gzip or Brotli

Use both. Brotli usually produces smaller text files than gzip, and every current browser supports it over HTTPS. gzip is the fallback for everything else. The trade-off is CPU: high Brotli levels are slow, so don’t use them for on-the-fly compression. The good pattern is to precompress static assets at build time at the highest level (app.js.br, app.js.gz next to app.js) and let the server pick the file, and use a moderate level for dynamic responses.

nginx

gzip on;
gzip_vary on;
gzip_comp_level 5;
gzip_min_length 1024;
gzip_proxied any;
gzip_types text/css application/javascript application/json image/svg+xml application/xml text/plain;
gzip_static on;   # serve app.js.gz if it exists

text/html is always compressed once gzip on is set, so it doesn’t go in gzip_types. gzip_static needs nginx’s gzip_static module, which most distribution packages include. gzip_proxied any matters behind a CDN or load balancer that adds a Via header: by default nginx doesn’t compress responses to requests that carry one.

Brotli isn’t part of stock nginx. It comes from the ngx_brotli module, which some distributions package; with it loaded the directives mirror gzip’s (brotli on;, brotli_comp_level, brotli_types, brotli_static on;).

Apache

<IfModule mod_deflate.c>
    AddOutputFilterByType DEFLATE text/html text/css application/javascript application/json image/svg+xml text/plain
</IfModule>
<IfModule mod_brotli.c>
    AddOutputFilterByType BROTLI_COMPRESS text/html text/css application/javascript application/json image/svg+xml text/plain
</IfModule>

mod_brotli ships with Apache 2.4.26 and later. Enable the modules (a2enmod deflate brotli on Debian and Ubuntu), then verify with both curl commands above that br and gzip requests each get the encoding they asked for.

Cloudflare and other CDNs

Cloudflare compresses compressible content types for visitors itself, choosing gzip or Brotli from the visitor’s Accept-Encoding. You usually have nothing to switch on there, but the curl checks still apply: test through the CDN, which is what visitors get.

Cache-Control: two policies, not one

Most caching mistakes come from treating every file the same. A site needs at least two policies.

Files with a hash in the name

Build tools emit names like app.3f9a1c.js. When the content changes, the name changes, so the old URL can be cached forever:

Cache-Control: public, max-age=31536000, immutable

immutable tells the browser not to revalidate even when the user reloads. Firefox and Safari honour it. Chrome doesn’t implement it, but it no longer revalidates subresources on an ordinary reload either, so the result is similar.

HTML

The HTML is what points at the new hashed files, so it must never be served stale after a deploy:

Cache-Control: no-cache

no-cache doesn’t mean “don’t cache”. It means “store it, but check with the server before using it”. With an ETag or Last-Modified header (next section), that check usually returns a 304 with no body, which is fast. no-store is the one that means don’t keep a copy at all; use it for pages with sensitive personal data, such as account and banking pages.

For pages that differ per logged-in user, add private so shared caches (CDNs, corporate proxies) never store them: Cache-Control: private, no-cache.

Everything else

Files without a hash in their name, like /logo.png or /css/style.css, need a compromise: a modest max-age (an hour, a day) plus validators. Better still, put them through the build so they get hashed names, or append a version query string (style.css?v=42) and change it on every deploy.

Mistakes worth checking for

  • No Cache-Control at all doesn’t mean “not cached”. Browsers apply heuristic freshness to responses that have a Last-Modified date, often a fraction (commonly 10%) of the time since the file last changed. That’s how a deploy ends up with some visitors seeing old HTML for hours. Always set an explicit policy on HTML.
  • Expires and Pragma. max-age overrides Expires. Pragma: no-cache is an HTTP/1.0 request header and does nothing useful in a response for modern caches.
  • Long max-age on unhashed files. If /js/main.js is cached for a year and you change it, returning visitors keep the old version until it expires or they clear their cache. There’s no way to recall it.

nginx and Apache examples

location /assets/ {
    add_header Cache-Control "public, max-age=31536000, immutable" always;
}
location = /index.html {
    add_header Cache-Control "no-cache" always;
}

Remember nginx’s add_header inheritance: a location with its own add_header drops every add_header from the server block, security headers included. Include your security header snippet in these locations too; the HTTP security headers guide has the details.

<FilesMatch "\.[0-9a-f]{8,}\.(js|css|woff2|png|jpg|webp|svg)$">
    Header set Cache-Control "public, max-age=31536000, immutable"
</FilesMatch>
<FilesMatch "\.html$">
    Header set Cache-Control "no-cache"
</FilesMatch>

Adjust the pattern to however your build names hashed files.

ETag and Last-Modified

These are validators. The server sends one with the response; next time, the browser asks “has it changed since this?” and the server answers 304 Not Modified, with no body, if it hasn’t.

curl -sI https://example.com/ | grep -iE '^(etag|last-modified)'
etag: "65f1c2a0-bd3"
last-modified: Tue, 06 Oct 2026 09:12:00 GMT
curl -sI -H 'If-None-Match: "65f1c2a0-bd3"' https://example.com/ | head -1
HTTP/2 304

If you always get 200 back:

  • Several servers behind a load balancer generate different ETags for the same file, for example because ETags are derived from file modification times and each server got the deploy at a slightly different moment. Requests bounce between servers and never match. Make the ETag depend on content, or turn ETags off and rely on Last-Modified with consistent timestamps.
  • The application builds the page dynamically and sends no validator. Many frameworks need middleware or a setting to produce ETags for dynamic responses.
  • A weak ETag (W/"…") is fine. nginx turns strong ETags into weak ones when it compresses a response, and conditional requests still work.

The CDN layer

A CDN adds a second cache with its own rules. Things that commonly stop it from caching:

  • Set-Cookie on the response. Many CDNs won’t cache responses that set cookies, and you shouldn’t want them to. Make sure static files don’t set session cookies.
  • Vary: Cookie or Vary: User-Agent. These split the cache into near-unique entries.
  • Unique query strings. Tracking parameters (?utm_source=…, ?fbclid=…) can make each URL a separate cache entry unless the CDN is configured to ignore them.
  • private or no-store from the origin, often set globally by a framework.

s-maxage sets a lifetime for shared caches separately from browsers. Cache-Control: public, max-age=0, s-maxage=300 lets the CDN serve HTML for five minutes while browsers always revalidate. Combine it with a purge on deploy. If a change is visible on the origin but not through the CDN, that’s a cache problem, not a DNS one; CDN cache vs DNS propagation explains how to tell them apart.

Order of work

  1. Fix redirect chains and the first byte of the HTML. Nothing else helps if the page itself starts late.
  2. Turn on compression for text, and confirm with curl that br and gzip requests each get compressed responses.
  3. Give built assets hashed names and a one-year immutable policy; give HTML no-cache with validators.
  4. Check CDN cache status for both HTML and assets.
  5. Re-run the HTTP check and the curl commands. The HTTP check shows the first-response time, Content-Encoding and Cache-Control of the final response together. These are reported as notes and don’t count toward its security grade.

Check your own domain now

Free, no sign-up. Runs the exact check this guide describes and shows what to fix.