FCP & TTFB Analysis
Time to First Byte (TTFB) and First Contentful Paint (FCP) are the two precursor metrics that govern every paint-based score a real user ever sees: nothing renders before the first byte arrives, and the largest element cannot paint before the first contentful pixel does. Treating them as a measured, segmented, CI-gated pair — rather than a Lighthouse afterthought — is the cheapest way to protect the load-phase budget that ultimately determines LCP Measurement & Optimization, and it sits squarely inside the discipline established in Core Web Vitals & Performance Metrics Fundamentals.
This page covers how to collect TTFB and FCP from real users with the Navigation Timing and Paint Timing entries, how to decompose TTFB into its constituent network phases, the thresholds Google holds you to, the optimization techniques that actually move each phase, and the failure modes that quietly corrupt your field aggregates.
responseStart; the FCP − TTFB gap is pure client-side render cost, and LCP almost always trails FCP. Aggregate each marker at p75 across your sampled population.Thresholds and the engineering action each band implies
TTFB is not an official Core Web Vital — there is no field-data ranking penalty tied directly to it — but Google publishes a diagnostic threshold for it because it bounds every other load metric. FCP is a published metric in PageSpeed Insights and CrUX. Hold yourself to the p75 of your real-user distribution, not the median, and not a synthetic lab number.
| Metric | Good (p75) | Needs Improvement (p75) | Poor (p75) | Primary engineering action |
|---|---|---|---|---|
| TTFB | ≤ 800 ms | ≤ 1800 ms | > 1800 ms | Edge-cache HTML, cut redirect hops, shrink server processing |
| FCP | ≤ 1.8 s | ≤ 3.0 s | > 3.0 s | Inline critical CSS, font-display: swap, defer blocking JS |
| FCP − TTFB delta | ≤ 1.0 s | ≤ 1.5 s | > 1.5 s | Render-path work: critical CSS, preconnect, streaming SSR |
The delta row is a derived diagnostic, not a Google metric, but it is the single most useful number on the page: it tells you whether a regression belongs to the backend/CDN (TTFB moved) or the critical rendering path (the gap moved). A page with a 1200 ms TTFB and a 1.9 s FCP has a server problem; a page with a 300 ms TTFB and a 1.9 s FCP has a render-path problem. The two demand completely different fixes.
Decomposing TTFB into its network phases
A single TTFB number is almost useless for debugging. The PerformanceNavigationTiming entry exposes the full connection chain as a sequence of high-resolution timestamps, all relative to navigation start. Subtracting adjacent timestamps yields the duration of each phase: redirect, DNS lookup, TCP connect, TLS negotiation, request send, and server processing (the wait between request and first response byte).
function decomposeNavigationTiming() {
const [nav] = performance.getEntriesByType('navigation');
if (!nav) return null;
// Server processing is the wait after the request is sent until the
// first response byte. The connection setup phases precede it.
const phases = {
redirect: nav.redirectEnd - nav.redirectStart,
dns: nav.domainLookupEnd - nav.domainLookupStart,
tcp: nav.connectEnd - nav.connectStart,
// TLS only populates when secureConnectionStart > 0
tls: nav.secureConnectionStart > 0
? nav.connectEnd - nav.secureConnectionStart
: 0,
request: nav.responseStart - nav.requestStart,
// TTFB itself: navigation start to first response byte
ttfb: nav.responseStart,
};
// nextHopProtocol is 'h3' for HTTP/3, 'h2' for HTTP/2, etc.
return { ...phases, protocol: nav.nextHopProtocol };
}
Two field semantics trip people up constantly. First, startTime on a navigation entry is always 0, so TTFB is responseStart directly (navigation start is the zero point), not responseStart - startTime. If you want to exclude redirect overhead from the reported value — which is what the web-vitals library’s onTTFB reports — you subtract nav.activationStart and clamp redirect time out. Second, secureConnectionStart is 0 for plaintext HTTP and for connections reused from the pool, so guard the TLS calculation as shown above or you will log negative handshake times.
Production collection with PerformanceObserver
Use a single PerformanceObserver registered with buffered: true so it replays entries that fired before your script ran — a common case for the navigation entry and the first-contentful-paint paint entry, both of which are typically available before any third-party script executes. FCP comes from the paint entry’s startTime; TTFB comes from the navigation entry’s responseStart.
const collected = {};
function record(name, value, attribution) {
collected[name] = {
value: Math.round(value),
route: location.pathname,
conn: navigator.connection?.effectiveType || 'unknown',
deviceMemory: navigator.deviceMemory || null,
...attribution,
};
}
const po = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.entryType === 'navigation') {
record('TTFB', entry.responseStart, {
dns: Math.round(entry.domainLookupEnd - entry.domainLookupStart),
tcp: Math.round(entry.connectEnd - entry.connectStart),
protocol: entry.nextHopProtocol,
cacheState: entry.serverTiming?.find(t => t.name === 'cache')?.description || null,
});
} else if (entry.name === 'first-contentful-paint') {
record('FCP', entry.startTime);
}
}
});
po.observe({ type: 'navigation', buffered: true });
po.observe({ type: 'paint', buffered: true });
Finalizing the beacon on page lifecycle
FCP and TTFB are settled early, but you should still flush them on the terminal lifecycle event rather than on a timer, so the beacon survives bfcache eviction and fast back-navigations. Listen for visibilitychange to hidden (the most reliable cross-browser terminal signal) with pagehide as a fallback, and flush exactly once. The payload goes to your self-hosted ingestion endpoint via sendBeacon, which the browser delivers even as the document unloads.
let flushed = false;
function flush() {
if (flushed) return;
if (!collected.TTFB && !collected.FCP) return;
flushed = true;
const payload = JSON.stringify({
sentAt: Date.now(),
metrics: collected,
});
navigator.sendBeacon('/rum/ingest', payload);
}
addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') flush();
});
// Safari historically fired pagehide more reliably than visibilitychange
addEventListener('pagehide', flush);
Reading Server-Timing for backend attribution
The Server-Timing response header is the only standard way to attribute the server-processing slice of TTFB to specific backend work, and it is readable from JS even cross-origin (the values are explicitly exposed). Emit it from your origin or edge worker — for example Server-Timing: cache;desc=HIT, db;dur=42, render;dur=88 — and read it back off the navigation entry’s serverTiming array, as the collection snippet above does for the cache state. This turns “TTFB regressed 200 ms” into “the db segment regressed 200 ms,” which is the difference between an actionable alert and a shrug.
Step-by-step debugging workflow
When p75 TTFB or FCP regresses in the field, work the funnel from symptom to fix in a fixed order so you do not optimize the wrong layer.
- Identify the moved number. Pull the per-day p75 for TTFB, FCP, and the FCP − TTFB delta. If TTFB moved, the problem is server/CDN/network. If only the delta moved, the problem is the critical rendering path. If both moved together, suspect a deploy that changed both the HTML response and its blocking assets.
- Trace the waterfall by phase. Break the regressed TTFB into the redirect/DNS/TCP/TLS/server phases from the decomposition above, segmented by the same dimension that regressed. A jump isolated to the server-processing slice points at backend code or a cold cache; a jump in DNS/TCP/TLS points at edge routing or a new third-party origin.
- Correlate overlapping signals. Cross-reference
Server-Timingsegments andnextHopProtocol. A population that dropped fromh3toh2(HTTP/3 to HTTP/2) often explains a TTFB increase on lossy mobile networks where connection migration matters. - Reproduce in the lab. Confirm with Chrome DevTools Performance panel under throttling that matches the regressed cohort’s
effectiveType. Lab repro with a forced cache MISS isolates origin work from edge-cache benefit. - Ship the fix behind a flag. Whether the fix is edge caching, a redirect removal, or inlined critical CSS, deploy it to a fraction of traffic and read the field delta on that cohort before full rollout.
- Monitor the delta. Watch the p75 of the affected segment for several days. Field metrics lag lab fixes because CrUX and your own aggregates are trailing windows; a same-hour “it looks fixed” is noise.
Field-data analysis: segmentation that exposes the cause
A global p75 hides every problem worth fixing. TTFB and FCP both vary by an order of magnitude across the dimensions below, so always slice before you conclude. These segments are far more meaningful at p75 of a properly sampled population than as raw counts.
| Segment | Why it diverges | Divergence to watch |
|---|---|---|
Device class (deviceMemory, CPU) |
Low-end CPUs slow parse/paint, inflating FCP not TTFB | FCP gap widens while TTFB is flat |
Network type (effectiveType) |
3g/slow-2g cohorts pay full RTT on every connection phase |
TTFB tail dominated by TCP/TLS, not server work |
| Geography / CDN PoP | Distance and routing change DNS/TCP/TLS; cache fill varies by region | One region’s TTFB p75 detached from the rest |
| Cache state (HIT/MISS) | A MISS pays full origin processing; a HIT is edge-only | Bimodal TTFB; the MISS mode sets the p75 |
| Navigation type | bfcache restores skip the network entirely | Suspiciously low TTFB cohort = bfcache, exclude it |
The cache-state row is the one that most often distorts aggregates: if 20% of your traffic hits a cold edge and pays a 1500 ms origin TTFB while 80% gets a 60 ms HIT, your p75 can land in either mode depending on the MISS rate, making the metric look unstable when the real story is cache-fill variance.
Optimization strategies by phase
Map each fix to the phase it actually shortens. Optimizing the render path will never help a TTFB problem, and a faster origin will never close an FCP gap caused by a blocking stylesheet.
| Technique | Phase shortened | Typical impact |
|---|---|---|
| Edge-cache HTML at the CDN | Server processing | TTFB MISS→HIT can drop p75 from ~1200 ms to ~80 ms |
| Eliminate redirect chains | Redirect | Each hop removed saves one full RTT (often 100–300 ms) |
HTTP/3 (h3) |
TCP + TLS setup | 0-RTT/connection migration trims handshake on lossy mobile |
preconnect / dns-prefetch for critical origins |
DNS + TCP + TLS | Warms the connection before the resource is needed |
| Inline critical CSS | FCP render path | Removes one render-blocking round trip; FCP drops sharply |
font-display: swap |
FCP render path | Text paints in fallback font instead of blocking on web font |
Streaming SSR (flush <head> early) |
TTFB + FCP | First byte arrives before the full page renders server-side |
For TTFB specifically, edge caching is the highest-leverage single change for most sites; the mechanics, cache-key design, and stale-while-revalidate patterns are covered in depth in Reducing TTFB with Edge Caching. For the question of which of the two metrics deserves your effort when crawlers and rankings are at stake, weigh the trade-off in TTFB vs FCP: What Really Matters for SEO.
Streaming SSR is worth a specific note because it blurs the line between the two metrics. When the server flushes <head> (with inlined critical CSS and preload hints) before the body is rendered, the browser receives its first byte sooner and can start the render path immediately, improving TTFB and FCP simultaneously. The cost is that responseStart now reflects the time-to-first-flush rather than time-to-complete-document, so a streaming origin and a buffered origin with the same total latency report very different TTFB — keep that in mind when comparing across frameworks.
Failure modes and gotchas
- Cache MISS variance. As above, a bimodal TTFB distribution driven by edge cache-fill rate makes p75 jumpy. Segment by cache state (via
Server-Timing) so the HIT and MISS modes are analyzed separately rather than blended. - Redirect chains hidden in TTFB. If you report raw
responseStart, every redirect’s DNS/TCP/TLS/server cost is folded into your TTFB. Awww→apex orhttp→httpsredirect can silently add hundreds of milliseconds. Decompose withredirectEnd - redirectStartto confirm, then remove the hop at the edge. - Plaintext and reused connections report TLS as 0.
secureConnectionStartis0for HTTP and for pooled connections, so always guard the subtraction. Logging negative TLS durations corrupts your phase histogram. - Cross-origin Server-Timing requires the header, not CORS.
Server-Timingvalues are exposed to JS withoutTiming-Allow-Origin, but the otherPerformanceResourceTimingfields (DNS, TCP, etc.) on cross-origin subresources are zeroed unless you sendTiming-Allow-Origin. Navigation timing for your own document is always fully populated. - Paint Timing gaps. Older Safari did not emit
first-contentful-paint. Fall back to the web-vitals library, which polyfills FCP behavior, rather than assuming the paint entry exists in every browser. - bfcache restores skew low. A back-forward cache restore produces a navigation entry with a near-zero network phase. Either exclude
type === 'back_forward'navigations or analyze them as their own cohort, or they will artificially deflate your TTFB p75.
CI/CD gating
Field data is the source of truth, but it trails a deploy by days, so gate the precursors synthetically in CI to catch regressions before they reach real users. Run a lab pass (Lighthouse or a scripted navigation) on a representative URL set with a forced cache MISS, assert TTFB and FCP against budgets a notch tighter than the field thresholds, and fail the build on regression.
# Assert FCP and TTFB budgets in CI with Lighthouse.
# A forced cache MISS measures the worst realistic origin path.
npx lighthouse https://staging.example.com/ \
--only-categories=performance \
--throttling-method=devtools \
--output=json --output-path=./lh.json \
--chrome-flags="--headless --no-sandbox"
node -e '
const r = require("./lh.json");
const a = r.audits;
const ttfb = a["server-response-time"].numericValue; // ms
const fcp = a["first-contentful-paint"].numericValue; // ms
const budgets = { ttfb: 800, fcp: 1800 };
const fails = [];
if (ttfb > budgets.ttfb) fails.push(`TTFB ${Math.round(ttfb)}ms > ${budgets.ttfb}ms`);
if (fcp > budgets.fcp) fails.push(`FCP ${Math.round(fcp)}ms > ${budgets.fcp}ms`);
if (fails.length) { console.error("Perf gate failed:\n" + fails.join("\n")); process.exit(1); }
console.log("Perf gate passed: TTFB " + Math.round(ttfb) + "ms, FCP " + Math.round(fcp) + "ms");
'
Treat the lab gate as an early-warning tripwire, not a substitute for field measurement: it catches a blocking-CSS regression or a new redirect in the pull request, while your RUM p75 confirms the real-world impact across the device and network mix your synthetic environment cannot reproduce.
FAQ
Is TTFB a Core Web Vital?
No. TTFB is a diagnostic metric, not a ranking-affecting Core Web Vital, and there is no direct field-data penalty tied to it. Google still publishes an ≤ 800 ms “Good” target because TTFB bounds every paint metric — including LCP — so a slow first byte caps your achievable scores.
How do I calculate TTFB from the Navigation Timing entry?
TTFB is the navigation entry’s responseStart, because startTime on a navigation entry is always 0. To exclude redirect and activation overhead the way the web-vitals library does, subtract activationStart and clamp out redirect time. Do not compute responseStart - startTime expecting a different number.
What does the FCP minus TTFB delta tell me?
It isolates pure client-side render cost from server/network latency. If the delta is large, the bottleneck is in the critical rendering path — render-blocking CSS or JS, slow fonts. If TTFB itself is large but the delta is small, the bottleneck is the backend, CDN, or network connection setup.
Why does my TTFB p75 look unstable day to day?
Almost always edge cache-fill variance. A cache MISS pays full origin processing while a HIT is edge-only, producing a bimodal distribution whose p75 flips between modes as the MISS rate drifts. Segment by cache state via Server-Timing and analyze the HIT and MISS populations separately.
Can I read Server-Timing across origins?
Yes. Server-Timing values are exposed to JavaScript without a Timing-Allow-Origin header, unlike the DNS/TCP/TLS fields of cross-origin subresources, which are zeroed unless you opt in. This makes Server-Timing the reliable channel for attributing the server-processing slice of TTFB to specific backend work.
Related
- TTFB vs FCP: What Really Matters for SEO — which precursor to prioritize for crawlers and rankings.
- Reducing TTFB with Edge Caching — cache-key design and stale-while-revalidate to collapse server-processing time.
- LCP Measurement & Optimization — the paint metric these two precursors bound.
- Synthetic vs Field Data Trade-offs — why the CI gate and the field p75 tell different truths.
- Self-Hosted Beacon Collection — the ingestion endpoint that receives these TTFB and FCP beacons.