6.4 — TTFB playbook¶
Module 6 · Lesson 4 · 🟡 Intermediate · ~35 min
TTFB is upstream of everything. A 900 ms TTFB caps your LCP at 900 ms no matter how perfect your images are. It's also the metric most affected by architecture, which makes it the highest‑leverage one to fix on a large commerce site.
Target: p75 ≤ 400 ms for cacheable pages, ≤ 800 ms for genuinely dynamic ones.
Step 1 — Decompose it¶
TTFB = network latency + server processing. Separate them before doing anything.
curl -s -o /dev/null -w '
dns: %{time_namelookup}s
tcp: %{time_connect}s
tls: %{time_appconnect}s
ttfb: %{time_starttransfer}s
total: %{time_total}s
─────────────────────────────
server: %{stderr}
' https://www.auroramarket.com/p/wool-overshirt-navy
dns: 0.021s
tcp: 0.048s
tls: 0.089s ← connection setup done at 89ms
ttfb: 0.910s ← server took 821ms
If ttfb - tls is large, it's your server. If tls itself is large, it's network/geography
and you need a CDN or multi‑region (8.6).
Server-Timing: the header that makes this easy¶
Emit a breakdown from your app so it shows up in DevTools and in RUM:
// lib/server-timing.ts
export class ServerTiming {
private marks: Array<{ name: string; dur: number; desc?: string }> = [];
async measure<T>(name: string, fn: () => Promise<T>, desc?: string): Promise<T> {
const t0 = performance.now();
try {
return await fn();
} finally {
this.marks.push({ name, dur: performance.now() - t0, desc });
}
}
toHeader(): string {
return this.marks
.map((m) => `${m.name};dur=${m.dur.toFixed(1)}${m.desc ? `;desc="${m.desc}"` : ''}`)
.join(', ');
}
}
// Usage in a route or middleware-adjacent wrapper
const timing = new ServerTiming();
const product = await timing.measure('db', () => getProduct(slug), 'product query');
const price = await timing.measure('pricing', () => getPrice(product.id));
// …
response.headers.set('Server-Timing', timing.toHeader());
// Server-Timing: db;dur=180.3;desc="product query", pricing;dur=120.7, render;dur=64.1
DevTools shows these in the Network panel's Timing tab, and PerformanceServerTiming makes them
available to RUM:
const nav = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming;
for (const t of nav.serverTiming ?? []) {
reportMetric({ name: `server_${t.name}`, value: t.duration });
}
Now "TTFB is 910 ms" becomes "TTFB is 910 ms, of which 480 ms is the pricing service" — and you know who to talk to.
Cause A — The route is dynamic when it shouldn't be¶
The single biggest TTFB cause in Next.js commerce apps.
Fix: 3.1 Choosing a rendering strategy, 3.6 PPR.
Expected gain: 400–900 ms. Nothing else in this playbook comes close.
Cause B — Request waterfalls¶
Sequential awaits mean TTFB is the sum of all your backend latencies.
Server-Timing: product;dur=180, inventory;dur=140, price;dur=120, reviews;dur=260, recs;dur=310
total: 1,010ms
If the durations sum to roughly your server time, you have a waterfall. If the total is close to the maximum single duration, you're already parallel.
// ❌ Sum: 1,010ms
const product = await getProduct(slug);
const inventory = await getInventory(product.id);
const price = await getPrice(product.id);
// ✅ Max: 180 + 140 = 320ms (the second group can't start until product resolves)
const product = await getProduct(slug);
const [inventory, price] = await Promise.all([
getInventory(product.id),
getPrice(product.id),
]);
// ✅✅ Best: stream everything not needed for the shell
Cause C — No CDN caching of HTML¶
# Request twice; the second should be a HIT with a non-zero age
curl -sI "$URL" | grep -iE 'x-cache|age|cache-control|x-nextjs-cache'
curl -sI "$URL" | grep -iE 'x-cache|age|cache-control|x-nextjs-cache'
| Symptom | Cause |
|---|---|
age: 0 every time |
Not cached — check Cache-Control for no-store/private |
x-cache: MISS on repeat requests |
Cache key fragmentation (tracking params, cookies, UA) |
HIT locally, MISS in production |
Multi‑POP cache; each POP warms separately (normal) |
| Random HIT/MISS | Cache key includes something high‑cardinality |
The cache‑key test:
curl -sI "$URL" | grep -i age
curl -sI "$URL?utm_source=newsletter" | grep -i age
# Different ages → tracking params are in your cache key → fix it
Cause D — Cold starts¶
Symptom: p50 is fine, p95/p99 is terrible.
SELECT
APPROX_QUANTILES(value, 100)[OFFSET(50)] AS p50,
APPROX_QUANTILES(value, 100)[OFFSET(75)] AS p75,
APPROX_QUANTILES(value, 100)[OFFSET(95)] AS p95,
APPROX_QUANTILES(value, 100)[OFFSET(99)] AS p99
FROM vitals WHERE name = 'TTFB' AND page_type = 'pdp';
-- p50 180 p75 240 p95 2,100 p99 3,400 ← cold starts
Mitigations:
| Approach | Effect |
|---|---|
| Long‑running servers (containers, not per‑request functions) | Eliminates it |
| Reduce server bundle size | Faster init |
| Lazy‑import heavy server dependencies | Faster init |
| Provisioned concurrency / min instances | Costs money, works |
| Warming requests on a schedule | Partial, fragile |
| Move initialization out of the module scope | Often the actual bug |
// ❌ Runs at module load — every cold start pays it
const heavyClient = new SomeSdk({ /* … */ });
const catalogIndex = buildIndex(largeJsonImport); // 400ms 💀
// ✅ Lazy, memoized, so only requests that need it pay, once per instance
let clientPromise: Promise<SomeSdk> | null = null;
function getClient() {
clientPromise ??= import('some-sdk').then((m) => new m.SomeSdk({ /* … */ }));
return clientPromise;
}
Cause E — Slow backend services¶
If Server-Timing shows one service dominating, that's a backend problem, not a frontend one — but
it's still yours to drive.
| Backend issue | Diagnosis | Fix |
|---|---|---|
| N+1 queries | Query count per request in APM | DataLoader / batch endpoints |
| Missing index | Slow query log | Add the index |
| No connection pooling | High connect time, pool exhaustion | Pool, or a proxy (pgbouncer‑style) |
| Cross‑region DB call | Latency ≈ RTT to another region | Read replica in the app's region |
| Over‑fetching | Large response payloads | Field selection / response shaping |
| No caching | Every request hits origin data | Data Cache, Redis |
| Cold connection per request | TLS handshake per call | Keep‑alive agent |
// Keep-alive matters more than people expect: a new TLS handshake per backend
// call adds 50-150ms EACH, and a PDP makes 5-6 calls.
import { Agent, setGlobalDispatcher } from 'undici';
setGlobalDispatcher(new Agent({
keepAliveTimeout: 30_000,
keepAliveMaxTimeout: 60_000,
connections: 128,
}));
Cause F — Middleware¶
Over 5 ms p95 is a problem. → 3.7
Cause G — Not streaming¶
curl -N -s -o /dev/null -w 'ttfb: %{time_starttransfer}s total: %{time_total}s\n' "$URL"
# ttfb: 0.910s total: 0.940s ← ttfb ≈ total means NO streaming
# ttfb: 0.190s total: 0.870s ← streaming ✅
If you've implemented Suspense boundaries and still see the first pattern, something in your infrastructure is buffering. Check:
- nginx:
proxy_buffering off;for the app upstream - Compression modules that buffer the whole response before compressing
- A CDN configuration with response buffering enabled
- An APM/security agent sitting in the response path
→ 3.3
Cause H — Geography¶
If TTFB varies strongly by user region, your compute is far from your users.
SELECT country, COUNT(*) AS n,
APPROX_QUANTILES(value, 100)[OFFSET(75)] AS p75_ttfb
FROM vitals WHERE name = 'TTFB' GROUP BY 1 ORDER BY n DESC LIMIT 15;
US p75 240ms
GB p75 380ms
DE p75 410ms
AU p75 890ms ← 14% of revenue, 3.7× the US latency
JP p75 940ms
Options, in order of cost‑effectiveness:
- Cache HTML at the CDN — turns a 890 ms origin fetch into a 30 ms edge hit. Fixes it for cacheable pages, which is most of your traffic.
- Multi‑region compute with regional read replicas — for the dynamic pages that remain.
- Edge rendering — only if the page needs little or no backend data (rarely true for commerce).
→ 8.6
The diagnostic flowchart¶
TTFB > 800ms?
│
├─ Is the route dynamic in the build output?
│ YES → make it static/ISR/PPR [3.1, 3.5, 3.6] −400..900ms
│
├─ Is x-cache MISS on repeat requests?
│ YES → fix cache headers / cache key [2.4, 7.2] −300..700ms
│
├─ Does Server-Timing sum ≈ total server time?
│ YES → waterfall; parallelize + stream [7.1, 3.3] −200..900ms
│
├─ Is p95 ≫ p75?
│ YES → cold starts; long-running servers [8.6] −100..2000ms p95
│
├─ Does one Server-Timing entry dominate?
│ YES → backend problem; take it to that team [7.1] varies
│
├─ Is ttfb ≈ total in a curl -N test?
│ YES → buffering; streaming is disabled [3.3] −300..1500ms
│
└─ Does TTFB vary by geography?
YES → CDN HTML caching, then multi-region [8.6] −200..600ms
Aurora's TTFB journey¶
| Change | p75 TTFB (PDP) |
|---|---|
| Baseline (fully dynamic, no HTML caching) | 910 ms |
| Parallelized data fetches | 690 ms |
| Streaming SSR (shell only awaits the product query) | 190 ms |
| Shared ISR cache handler (self‑hosted, was 1/N hit ratio) | 120 ms |
| CDN HTML caching with SWR | 95 ms |
| PPR | 85 ms |
| Middleware matcher tightened | 58 ms |
The shared cache handler line is worth calling out: they were self‑hosting on 180 pods, each with its own filesystem ISR cache. Hit ratio was 11%. One config change took it to 96%.
Common mistakes¶
| Mistake | Consequence |
|---|---|
| Measuring TTFB from your office | Your latency to your own datacenter isn't your users' |
No Server-Timing |
Server time is a black box |
| Assuming a CDN fixes TTFB | It fixes it only for cacheable responses |
| Optimizing TTFB while LCP is image‑bound | Fixing the wrong 5% |
| Ignoring p95 | Cold starts hide behind a good p75 |
| Testing streaming only locally | Production proxies buffer |
| Blaming the backend without measuring | Often it's your own waterfall |
| Caching HTML that contains personalized data | 🚨 Data leak — see 3.6 |
Checklist¶
-
Server-Timingemitted for every significant server phase - Server timing forwarded to RUM
- Route rendering modes verified in the build output and gated in CI
- CDN cache HIT verified on repeat production requests
- Cache key free of tracking params and raw UA
- Data fetches parallel; shell awaits only what it needs
- Streaming verified with
curl -Nagainst production - p95/p99 checked separately for cold starts
- Keep‑alive configured for backend HTTP calls
- TTFB segmented by geography, with a plan for the worst region