Skip to content

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.

npx next build | grep -E '^[├└┌]'
# ƒ /p/[slug]  ← dynamic
# Find the culprit
rg -n 'cookies\(\)|headers\(\)|force-dynamic|connection\(\)|draftMode' app/

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

7.1 Waterfalls & API design


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

2.4, 7.2

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,
}));

7.1, 8.6


Cause F — Middleware

res.headers.set('server-timing', `mw;dur=${Date.now() - t0}`);

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:

  1. 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.
  2. Multi‑region compute with regional read replicas — for the dynamic pages that remain.
  3. 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-Timing emitted 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 -N against 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

Next: Module 7 — Data & backend