Fixing a slow website: locate the bottleneck with TTFB, then decide on CDN and cache headers — plus what compression and HTTP/2 buy you.
A slow website is the most common and most frequently misdiagnosed performance problem. The delay can sit in DNS, TTFB, image weight, JavaScript execution — or simply in misconfigured cache headers. This guide follows the real diagnostic order: locate the bottleneck with a command, then decide whether you need a CDN, how long to cache, and how hard to compress images — rather than buying a CDN first and guessing afterwards.
Order of judgement: TTFB first, transfer weight second, render blocking third. A CDN only fixes part of the first two; the third is a code and build problem.
Locate the bottleneck first
Split a page load into four stages and the problem usually reveals itself: connection setup (DNS + TCP + TLS), time to first byte, transfer (HTML / CSS / JS / images), and parse and render (JS execution, layout, paint). Each stage needs a different fix, and reaching for the wrong tool is how teams waste money.
- TTFB above 600ms: the bottleneck is the backend or the region. Look at a CDN and a closer origin
- TTFB under 200ms but slow first paint: the bottleneck is asset weight and blocking. Compress images, cut requests
- Fast repeat visits, slow first visit: caching already works — optimise the cold path instead (warm-up, inline critical CSS)
One command gives you the four numbers you need:
curl -s -o /dev/null -w "dns:%{time_namelookup}s tcp:%{time_connect}s ttfb:%{time_starttransfer}s total:%{time_total}s\n" https://example.com/
time_starttransfer is TTFB. Re-run with -H 'Cache-Control: no-cache': if TTFB jumps noticeably, the earlier number was a CDN cache hit rather than the origin's real response time.
What a CDN actually does: edge PoPs, reverse proxy, cache hits
A CDN is fundamentally a reverse proxy plus a cache, placed next to your users. It keeps cacheable content on edge nodes (PoPs) in major cities and inside ISPs, so requests are served nearby instead of travelling back to origin every time. How to compare coverage, billing granularity and origin-fetch rules: CDN acceleration.
- Lower latency: 5–30ms to a nearby edge node replaces a 100–200ms round trip to origin
- Cache hits: nothing goes back to origin; static-asset hit ratios commonly sit at 80–95% in practice
- Origin offload: bandwidth and connection pressure move to the CDN; keep only fallback capacity on origin
- Protocol upgrades: most CDNs enable HTTP/2, HTTP/3 and TLS 1.3 for you
Confirming a hit: read the response header. Cloudflare uses cf-cache-status: HIT, AWS CloudFront uses x-cache: Hit from cloudfront, Fastly uses X-Cache: HIT, and Alibaba DCDN / Tencent Cloud CDN typically use X-Cache. Field names differ per vendor — run curl -sI https://your-domain/ once before drawing conclusions.
On a cache miss, a CDN can be marginally slower than hitting origin directly, because of the extra hop. That is why the rules must be paired: long TTL for static assets, short TTL for HTML. Long-cached HTML is the most common way a CDN rollout goes wrong.
TTFB caps your first paint and your conversion rate
TTFB (Time To First Byte) is the gap between sending a request and receiving the first byte. It sets when HTML starts downloading, and therefore largely sets the floor for LCP — in practice TTFB often accounts for more than half of LCP. Google treats TTFB under 800ms as good, while Core Web Vitals require LCP under 2.5s; spend more than that and the window left for rendering is simply gone.
The more practical issue is revenue. In Google and SOASTA's large retail performance study, every 1% drop in mobile page speed correlated with roughly a 1% drop in sales; in an A/A test that deliberately sped pages up, a 20% faster first paint corresponded to about a 9% lift in conversion. Those figures come from a specific sample and are not your forecast — but the direction and magnitude are enough to make the point: TTFB is a revenue line, not a matter of tidiness.
Realistic targets and levers:
- With CDN hits, hold TTFB under 200ms
- Place the origin in a region close to your users — every 100ms of cross-border round trip saved reads to users as "this page feels faster"
- Add page-level or reverse-proxy caching to avoid repeated database queries
- Move anything not needed for first paint (recommendations, comments, third-party scripts) to async loading
Did it actually get faster? If TTFB dropped from 150ms to 30ms but total time did not move, the bottleneck has moved to transfer weight or rendering — stop tuning cache headers and go work on images and JavaScript.
Cache-Control: put the policy in the response header
Browsers and CDNs only read headers. If your caching policy is not in Cache-Control, the default behaviour is to revalidate on every request, which means caching is effectively off. These four patterns cover most cases.
Hashed JS / CSS
Cache-Control: public, max-age=31536000, immutable
HTML pages
Cache-Control: public, s-maxage=60, stale-while-revalidate=300
Versioned images
Cache-Control: public, max-age=2592000
Logged-in pages / personal data
Cache-Control: private, no-store
max-ageis for browsers;s-maxageis for shared caches such as CDNs and takes precedenceno-cachedoes not mean no caching: it may be stored but must always be revalidated (a 304 with no body is fine)no-storemeans never write to any cache — the only correct choice for personal dataimmutableskips revalidation even on refresh; only safe when the file URL carries a hashstale-while-revalidateserves the stale copy while refreshing in the background — strongly recommended for HTML
Compression only works if you also send Vary: Accept-Encoding, otherwise the CDN may hand a brotli body to a client that cannot decode it, or the gzip body to everyone.
Choosing TTL: static versus dynamic content
TTL is a two-sided trade-off: staleness (how long before an update is visible) versus origin traffic (how many requests reach your servers). Static assets get a long TTL and updates move into the filename hash; dynamic content trades freshness for a short TTL. Moving static files off the app server into object storage and putting the CDN in front is usually the easiest win here — storage tiers and lifecycle rules: AWS S3 storage tiers.
| Content type | Suggested Cache-Control | TTL | Notes |
|---|---|---|---|
| Hashed JS / CSS | public, max-age=31536000, immutable | 1 year | Change content, change filename — safe to cache hard |
| Versioned images / fonts | public, max-age=2592000 | 30 days | Rotate the URL or filename to bust it |
| HTML pages (home, articles) | public, s-maxage=60, stale-while-revalidate=300 | 60s – 5min | Effectively visible right after deploy |
| Public GET APIs (lists, quotes) | public, s-maxage=5-30 | 5–30s | Set by how stale the data may be |
| Authenticated APIs / cart / orders | private, no-store | Not cached | Any shared-cache hit here is a data leak |
| Logged-in HTML | private, no-cache | Revalidate each time | Stops one user seeing another user's page |
| Third-party scripts and fonts | Vendor default | — | Self-host anything on the critical path |
Start dynamic content at 5–30 seconds, not zero: watch how often the data actually changes. If an endpoint updates a few times a day, a 60-second TTL is fine and cuts origin traffic by an order of magnitude.
How browser, CDN and origin caches divide the work
A request passes through three layers, and solving it at the earliest layer is always cheaper. Each layer is configured and invalidated independently.
- Browser cache: governed by
max-age; repeat visits make zero network requests (shown as memory cache / disk cache in DevTools) - CDN cache: governed by
s-maxage; a hit never reaches origin and is where you save origin bandwidth - Origin cache (Nginx or application page cache): the last line of defence, and what actually sets TTFB when the CDN misses
When the CDN does not cache — or after a purge — browser and origin fall back to conditional requests: an ETag (or Last-Modified) with If-None-Match gets a 304 Not Modified back when nothing changed, and no body is transferred. Conditional caching saves bandwidth and time; it does not reduce the number of requests.
Never cache HTML for long. Serving stale markup after a deploy is worse than being slow: broken features and 404s on links. HTML TTLs should be as short as the business can tolerate.
Compression and images: saved bytes are saved time
Transfer time scales with bytes. On 10 Mbps, 1MB takes about 0.8 seconds and 300KB about 0.25 seconds — not theory, multiplied straight into first paint.
- Text compression: gzip typically cuts HTML / CSS / JS by 60–80%; brotli is a further 15–25% smaller than gzip at comparable quality
- Image formats: WebP commonly saves 25–34% over JPEG, and AVIF commonly saves around 50% over JPEG
- Right size per screen: serve 2× images to 2× displays with
srcset+sizesinstead of one large file for everyone - Lazy load: add
loading="lazy"below the fold — but never to the LCP image - Reserve dimensions: set
width/heighton images to keep CLS at or below 0.1
The LCP image needs special handling: add fetchpriority="high" or <link rel="preload" as="image"> to get the request started early, and exclude it from lazy loading. It is the largest first-paint asset; if it loses the request queue, better compression buys you nothing.
Most CDNs can convert to WebP / AVIF based on the Accept request header and compress with brotli at the edge. Before enabling it, check two things: whether your vendor supports it, and whether both formats share the same cache key — otherwise one URL caches two variants and your hit rate drops for free.
HTTP/2 and HTTP/3: what connection reuse is worth
A page with 40 static assets typically opens six connections and repeats TCP plus TLS handshakes over HTTP/1.1. HTTP/2 multiplexes them over a single connection with HPACK header compression, so one handshake covers the whole page and real-world tests usually save 100–300ms of first paint.
- Enable H2 on both CDN and origin. CDN-only H2 still sends the edge-to-origin leg over HTTP/1.1 and halves the benefit
- Stop bundling everything into one giant file and stop sharding domains across subdomains — those were HTTP/1.1 workarounds and now slow parsing
- HTTP/3 runs over UDP/QUIC: it supports 0-RTT resumption and better loss recovery, which is a visible win on mobile networks
Roll out HTTP/3 gradually. Some enterprise firewalls, older carrier networks and middleboxes drop UDP. Verify the handshake with curl --http3 -s -o /dev/null -w "%{http_version} %{time_starttransfer}s\n" https://example.com/, and make sure failure falls back to HTTP/2 automatically.
Invalidating caches when content changes
Long TTLs mean long-lived staleness. Three approaches, in descending order of reliability.
- Versioned URLs (preferred): put a content hash in the filename or query string, e.g.
app.4f3a2b.js. New content is a new URL, so no purge is needed and hit rates are unaffected - Purge by URL or prefix: use this only when the URL must stay stable. A purge request from your deploy pipeline usually propagates globally within seconds to tens of seconds
- Warm-up: after a deploy, pull the new version back to the edge explicitly so a cold start does not stampede origin
How fast each approach lands: versioned URLs are live on first request with no action at all; a per-URL purge typically reaches the whole network within seconds to tens of seconds, though rate limits vary by API; warm-up pulls the new version forward on purpose, which is the cheapest option but costs you one extra step in the pipeline.
Purging is not free: a full purge causes an origin thundering herd. On a high-traffic site run it during a quiet window, or configure rate limiting plus automatic warm-up. Re-check with curl -sI right after purging to confirm the new version is being served before you ship anything else.
Measuring it: Lighthouse, WebPageTest and a TTFB baseline
Optimisation requires a baseline. Without a before/after comparison every performance claim is guesswork. Three tools, three jobs:
- Lighthouse (Chrome DevTools → Lighthouse, or
npx lighthouse https://example.com --only-categories=performance): reports LCP, CLS, TBT and the "reduce initial server response time" audit - WebPageTest: multi-location TTFB and First Contentful Paint, with First View separated from Repeat View so you can see whether the CDN is working
- DevTools Network waterfall: read each row's Size column,
200 / 304status, Priority, and thememory cache/disk cachemarkers
| Metric | Good | Poor | Tool |
|---|---|---|---|
| TTFB | ≤ 0.8s (≤ 0.2s with CDN hits) | > 1.8s | WebPageTest / curl |
| LCP | ≤ 2.5s | > 4.0s | Lighthouse / CrUX |
| INP | ≤ 200ms | > 500ms | Lighthouse / RUM |
| CLS | ≤ 0.1 | > 0.25 | Lighthouse |
Lab data is not field data. Office networks are fast and quiet, which flatters everything. Where budget allows, add RUM or at least re-check from multiple WebPageTest locations, so you do not optimise only for the test tool.
A one-week optimisation checklist
Work in this order and you should see measurable movement in TTFB and first paint within a week. Every step has a number attached to it.
- Day 1 — baseline: record DNS / TCP / TTFB / total with the
curlcommand above, then run Lighthouse and file all four numbers - Day 2 — diagnose: identify every endpoint above 600ms TTFB and the five heaviest assets; ignore everything else for now
- Day 3 — cache headers: give hashed JS / CSS a one-year
immutableTTL, HTMLs-maxage=60plusstale-while-revalidate, and confirmVary: Accept-Encoding - Day 4 — CDN: onboard static assets only, verify the
X-Cacheheader and TTFB under 200ms, then consider HTML - Day 5 — compression and images: enable brotli, automatic WebP / AVIF conversion, and give the LCP image
fetchpriority="high"with no lazy load - Days 6–7 — protocols and recheck: confirm H2 on CDN and origin, canary HTTP/3, then re-run Lighthouse and WebPageTest against the baseline
Next step: start with the first three items — baseline plus cache headers. That needs no new purchase and usually removes a large share of repeated requests. If TTFB is still high after that, evaluate a CDN and a closer origin region — and bring the three numbers from the curl command (TTFB / total / whether it was a cache hit) as a table, which makes comparing proposals far more efficient.