Improving TTFB for Static HTML
Time to first byte is the part of page loading that static sites are supposed to have solved. There is no database query, no template rendering, no application server — just a file. Yet field data regularly shows static sites with TTFB at p75 of 600 ms, 800 ms or more on mobile, and since LCP cannot start before the HTML arrives, that time comes straight off the LCP budget. The cause is never generating the page; it is where the page comes from and how many round trips it takes to get there.
This guide breaks TTFB into its parts for a static site, measures each in the field, and fixes the ones that matter: HTML not cached at the edge, redirect chains, connection setup and origin placement, plus 103 Early Hints for what remains. It is part of Largest Contentful Paint Optimization for Static Sites. The site is a documentation site served from S3 in Frankfurt behind a CDN, with a global audience; its mobile TTFB at p75 was 640 ms before this work.
Prerequisites
- Field TTFB with sub-parts from the Navigation Timing API (the
web-vitalsattribution build reports them). - CDN analytics or logs with cache status for HTML.
- Access to CDN caching and redirect configuration.
Step 1: Break TTFB Into Its Parts
The web-vitals attribution build splits TTFB into waiting time (before the request could start, including redirects), DNS, connection (TCP and TLS), and request time (from sending the request to the first byte):
import { onTTFB } from 'web-vitals/attribution';
onTTFB(({ value, attribution: a }) => beacon({
metric: 'TTFB', value, cacheStatus: performance.getEntriesByType('navigation')[0]?.serverTiming?.find((t) => t.name === 'cdn-cache')?.description,
waiting: a.waitingDuration, dns: a.dnsDuration, connection: a.connectionDuration, request: a.requestDuration
}));
The CDN was configured to add a Server-Timing: cdn-cache;desc=HIT header, so each beacon records whether the HTML came from the edge cache. A week of mobile data at p75:
| Part | p75 | Main cause |
|---|---|---|
| Waiting (incl. redirects) | 190 ms | www and trailing-slash redirects from shared links |
| DNS | 40 ms | first visit lookups |
| Connection (TCP + TLS) | 110 ms | TLS 1.3 without 0-RTT on some paths |
| Request (to first byte) | 280 ms | 41% of HTML requests missed the edge cache |
Step 2: Cache HTML at the Edge
The largest part was request time on cache misses: HTML was served with Cache-Control: max-age=0, must-revalidate, which CDNs interpret as "revalidate with the origin every time". Each revalidation is a round trip to Frankfurt — about 20 ms for European readers and 300 ms or more for Asia-Pacific. The fix is to let the edge cache HTML for a short time and serve stale while revalidating, while browsers still revalidate:
Cache-Control: public, max-age=0, s-maxage=300, stale-while-revalidate=86400
s-maxage=300 lets the CDN serve the HTML for five minutes without asking the origin; stale-while-revalidate lets it serve a stale copy while fetching a new one in the background; deploys purge HTML so new content appears immediately. The details and trade-offs are in Stale-While-Revalidate for Static HTML. Tiered caching, as in Tiered Caching and Origin Shield, then served most remaining misses from a regional tier. The HTML edge hit ratio went from 59% to 95%.
Step 3: Remove Redirect Hops
Waiting time was dominated by redirects. Analytics showed that 23% of landing navigations arrived through at least one redirect: www. to the apex domain, slash-less URLs to trailing-slash URLs, and old paths from a migration — and some links from the company's own marketing emails chained all three. Each hop is a full round trip before the real request can begin.
Three changes: marketing links and the site's own internal links were updated to final URLs (a link checker in CI now treats internal redirects as errors, as in Checking Links in Pull Requests); the redirect rules were flattened so every old URL goes to its final destination in one hop; and redirects moved from the origin to the edge, so the remaining hops take one short round trip to the nearest edge rather than one to Frankfurt, as described in Caching 404s and Redirects at the Edge.
Step 4: Connection Setup
Connection time — TCP plus TLS — is paid once per origin per visit, but it is paid on the navigation request, so it lands directly in TTFB. Checks and changes:
- TLS 1.3 and HTTP/3 enabled at the CDN. HTTP/3 over QUIC combines transport and TLS handshakes; on this site's mobile traffic, 58% of navigations used HTTP/3 after enabling it, and their connection time at p75 was 38 ms lower than HTTP/2.
- Certificate chain size. A certificate chain with an extra intermediate added a round trip for some clients; switching to the CDN's managed certificate with an ECDSA key and a short chain removed it.
- DNS. A low TTL of 60 seconds on the apex record, left over from a migration, caused frequent re-resolution; raising it to one hour cut DNS time at p75 from 40 ms to 22 ms.
Step 5: Use 103 Early Hints for What Remains
Even a fast TTFB is time the browser spends idle. 103 Early Hints lets the CDN send Link headers for critical resources before the final response, so the browser starts fetching the stylesheet and hero image while the HTML is still on its way. On Cloudflare, Early Hints are generated from Link headers the site already sends and replayed from cache; details are in Early Hints 103 on Cloudflare. On this site, once HTML was cached at the edge, Early Hints helped mainly on the remaining cache misses, where it overlapped about 90 ms of origin wait with resource fetching.
Measured Impact
Two weeks of field data before and after, mobile:
| Measure | Before | After |
|---|---|---|
| TTFB p75, all regions | 640 ms | 190 ms |
| TTFB p75, Asia-Pacific | 1,080 ms | 240 ms |
| HTML edge hit ratio | 59% | 95% |
| Landing navigations with ≥1 redirect | 23% | 6% |
| Connection time p75 | 110 ms | 71 ms |
| LCP p75, all regions | 2.1 s | 1.6 s |
Pitfalls & Rollback
- Optimizing the origin for a cached site. When HTML is served from the edge, origin speed matters only for misses; fix the hit ratio first.
no-cacheormax-age=0withouts-maxage. Forces an origin round trip on every HTML request at many CDNs.- Redirect chains in your own links. Internal links and marketing emails are the easiest hops to remove.
- Measuring TTFB only in the lab. Lab tests from one region hide the cost for distant readers; use field data by region.
- Rollback: cache headers, redirect rules and protocol settings are configuration; each can be reverted independently, and a purge restores fresh HTML immediately.
Conclusion
A static site's TTFB is decided by the network, not the server: whether HTML is cached at the edge, how many redirects precede it, how long connection setup takes and how far the origin is from readers. Measure TTFB's parts in the field, cache HTML at the edge with short shared lifetimes and stale-while-revalidate, flatten and move redirects to the edge, enable HTTP/3, and let Early Hints use the time that remains. On this site that took mobile TTFB from 640 ms to 190 ms and LCP from 2.1 s to 1.6 s.
FAQ
What is a good TTFB for a static site?
Google's guidance treats 800 milliseconds at the 75th percentile as a rough threshold for good. A static site served from a CDN edge cache should do much better, typically 100 to 250 milliseconds at p75 on mobile, because no page is generated per request.
Why would static HTML have a slow TTFB?
The usual causes are HTML not being cached at the edge so every request goes to a distant origin, redirect chains before the final URL, slow connection setup from TLS or DNS, and origins such as object storage in a single region. Page generation is rarely the problem on a static site.
Do redirects count toward TTFB?
Yes. In the Navigation Timing API and in CrUX, TTFB is measured from the start of navigation, so every redirect hop before the final response adds its full round trip. A shortened link that redirects twice can double TTFB.
How does TTFB relate to LCP?
LCP cannot happen before the HTML arrives, so TTFB is the first part of every LCP. On static sites with well-optimized images, TTFB is often the largest remaining part of LCP, especially for readers far from the origin.
Related
- Parent: Largest Contentful Paint Optimization for Static Sites — all parts of LCP.
- Measuring LCP Subparts with DevTools — where TTFB fits in LCP.
- Cache Keys and Query Strings on CDNs — raising the HTML hit ratio.
- Precompressing Assets with Brotli and Zstd — smaller HTML once the first byte arrives.
- Uptime and Synthetic Checks for Static Sites — watching TTFB by region.
Filed underHow-tos