TTFB Optimization skill for Claude Code

Find the slow phase of the first byte, then fix only that. Claude measures before it optimizes: field TTFB per route, then a curl-based script that splits each request into DNS, TCP, TLS, server wait and download and reads Server-Timing. Only then does it pick the fix that matches the dominant phase, such as edge caching, removing redirect chains, co-locating compute with the database or parallelizing upstream calls.

  • namettfb-optimization
  • size180 lines · ≈2,250 tokens
  • files2
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Reduces Time to First Byte by splitting it into redirect, DNS, connect, TLS, service worker, and server-wait phases, instrumenting the request path with Server-Timing, and fixing the dominant phase — removing redirects, moving to an edge or CDN cache, co-locating compute with the database, parallelizing upstream calls, cutting cold starts, and flushing early with streaming or 103 Early Hints. Includes a curl-based breakdown script with budgets. Use when TTFB is high in CrUX or Lighthouse, HTML responses are slow, server response time is flagged, or pages are slow only on cache misses.

Try asking

  • PageSpeed says our server response time is 1.6 seconds. Figure out where it goes
  • Add Server-Timing to this Express app so we can see what slows the product page
  • TTFB is fine in the US but terrible in Australia — what should change?

Use it when

  • CrUX, Lighthouse or RUM reports high TTFB or slow server response
  • HTML pages are slow on cache misses or for logged-in users
  • Moving rendering to the edge or serverless and latency got worse
  • You need a TTFB budget check against staging in CI

Skip it when

How to check the result

Re-run the breakdown script from a user region and confirm the dominant phase shrank for both cache HIT and MISS, then watch field TTFB p75 trend down.

The common mistake it prevents

Testing only cache hits from a laptop near the server, which hides the cross-region round trips and cold misses real visitors pay.

~/.claude/skills/ttfb-optimization/SKILL.md

--- name: ttfb-optimization description: Reduces Time to First Byte by splitting it into redirect, DNS, connect, TLS, service worker, and server-wait phases, instrumenting the… license: MIT ---

TTFB is fixed when the field p75 for HTML documents is ≤ 800 ms (the “good” threshold), cacheable pages are served from a cache close to users, and every remaining millisecond of server wait is attributed to a named step in the request path. Do not change code until the breakdown says which phase dominates — a CDN does nothing for a 900 ms database call, and query tuning does nothing for a user 200 ms of round trips away.

Workflow

- [ ] 1. Get field numbers per route (p75, by country/device)
- [ ] 2. Break TTFB into phases (lab script + Navigation Timing)
- [ ] 3. Instrument server wait with Server-Timing
- [ ] 4. Fix the dominant phase (tables below)
- [ ] 5. Re-measure cache HIT and MISS separately, and under load
- [ ] 6. Report before/after per phase

1. Field data first

  • CrUX / PageSpeed Insights “Time to First Byte” for the origin and the key URLs.
  • RUM: web-vitals onTTFB with attribution gives waitingDuration, cacheDuration, dnsDuration, connectionDuration, requestDuration — log them per route and country.
  • In a browser console on the real page:
const n = performance.getEntriesByType("navigation")[0];
console.table({
  redirect: n.redirectEnd - n.redirectStart,
  swAndCache: n.domainLookupStart - n.fetchStart,   // includes service worker startup
  dns: n.domainLookupEnd - n.domainLookupStart,
  connectTls: n.connectEnd - n.connectStart,
  tls: n.secureConnectionStart ? n.connectEnd - n.secureConnectionStart : 0,
  serverWait: n.responseStart - n.requestStart,
  ttfb: n.responseStart - n.startTime,
});

High TTFB only in distant countries → network/edge problem. High everywhere → server.

2. Lab breakdown

python3 scripts/ttfb_breakdown.py https://example.com/products/42 -n 15
python3 scripts/ttfb_breakdown.py https://example.com/account -H "Cookie: session=..." -n 10
python3 scripts/ttfb_breakdown.py https://staging.example.com/ --budget 600   # exit 1 over budget
python3 scripts/ttfb_breakdown.py URL --raw > before.txt   # save; later: --from before.txt

It runs curl repeatedly and prints median/p90 for DNS, TCP, TLS, server wait, and download, aggregates Server-Timing entries, shows cache headers (age, cf-cache-status, x-vercel-cache, x-cache), and names the dominant phase. Run it from a machine in the same region as your users (or a cloud shell there), not only from a laptop next to the server.

3. Instrument the server

Add Server-Timing so each response explains itself in DevTools (Network → Timing):

// Express / Node: per-request timer
app.use((req, res, next) => {
  const marks: string[] = [];
  res.locals.time = async <T>(name: string, fn: () => Promise<T>) => {
    const t = performance.now();
    try { return await fn(); }
    finally { marks.push(`${name};dur=${(performance.now() - t).toFixed(1)}`); }
  };
  const start = performance.now();
  const writeHead = res.writeHead;
  res.writeHead = function (...args: any[]) {
    marks.push(`app;dur=${(performance.now() - start).toFixed(1)}`);
    res.setHeader("Server-Timing", marks.join(", "));
    return writeHead.apply(this, args as any);
  };
  next();
});
// usage: const user = await res.locals.time("db-user", () => db.user.find(id));
# FastAPI / Starlette middleware
@app.middleware("http")
async def server_timing(request, call_next):
    t = time.perf_counter()
    response = await call_next(request)
    response.headers["Server-Timing"] = f"app;dur={(time.perf_counter() - t) * 1000:.1f}"
    return response
  • Name steps by what they do (auth, db-products, cms, render), not by function name.
  • Do not expose internal hostnames or query text; on public sites emit detail only for requests carrying an internal header, or keep names generic.
  • Add Timing-Allow-Origin only if cross-origin RUM must read the values.
  • For deeper attribution use tracing (opentelemetry) or a profiler (node-profiling, python-profiling, go-pprof).

Fix by phase

Network phases (redirect, DNS, connect, TLS)

FindingFix
Redirect > 0 on the landing URLLink to the final URL; collapse chains (http→https→www→/en/ is three round trips); use HSTS preload so http→https happens in the browser
Connect + TLS > ~100 ms for most usersServe HTML from a CDN/edge POP; terminate TLS at the edge; keep origin connections warm (the CDN reuses them)
TLS handshake slowTLS 1.3, ECDSA certificates, OCSP stapling, short chain; enable HTTP/3 (QUIC) at the CDN
DNS > ~50 ms p75Use an anycast DNS provider; avoid CNAME chains to third parties on the main hostname
swAndCache largeService worker startup on navigation: enable navigation preload or drop the fetch handler for navigations

Server wait

Finding (from Server-Timing / traces)Fix
Same HTML for every visitor, rendered per requestCache full pages at the CDN: Cache-Control: public, s-maxage=…, stale-while-revalidate=… (see http-caching); move the route to static or ISR (see rendering-strategies)
Several upstream calls in sequenceRun independent calls concurrently (Promise.all, asyncio.gather, errgroup); see async-concurrency
Many small DB queries per requestBatch / eager load — n-plus-one-queries; slow single query — sql-query-tuning
Compute in one region, database in anotherCo-locate: each query pays the cross-region RTT (e.g. 70–150 ms). Edge functions that make multiple DB queries to a central database are slower than one regional server
Auth middleware calls an identity provider on every requestVerify signed tokens locally; cache sessions; fetch JWKS once with caching
Serverless cold starts in p90/p99Minimum instances / provisioned concurrency for hot routes; shrink the bundle and lazy-load heavy SDKs; move init out of the handler only if it is reused
Waiting on a connection pool under loadPool sizing and queueing — server wait grows with concurrency while CPU is idle
Brotli at max level on dynamic HTMLUse level 4–6 for dynamic responses; precompress static assets at 11
Synchronous logging, analytics or email in the handlerMove off the request path (queue, waitUntil, background task)
Slow template/React render (> ~50 ms)Profile the render; cache fragments; stream the slow part

Flush early

When server work cannot shrink further, send bytes sooner:

  • Stream HTML: flush <head> (CSS, preloads) immediately, stream the body as data resolves (React renderToPipeableStream, Suspense in Next/Remix/SvelteKit).
  • 103 Early Hints with Link: </app.css>; rel=preload; as=style lets the browser fetch critical assets while the server thinks. Supported by major CDNs and Chromium browsers; verify your CDN forwards it.

Streaming improves TTFB without improving LCP if the LCP element waits for the slow data. Track LCP alongside TTFB so a streaming change is not a metric trick.

Gotchas

  • Measuring a cache HIT and calling it fixed. Measure MISS too (new query string only if the CDN keys on it; otherwise purge or use a staging bypass header) — MISS latency is what the first visitor per POP and every personalized request get.
  • Vary: Cookie or a Set-Cookie on public pages turns CDN caching off silently.
  • TTFB under load differs from idle TTFB: queueing inflates server wait while CPU looks fine. Confirm with a load test (load-testing) at expected peak.
  • Dev servers compile on demand; measure production builds only.
  • Bot and health-check traffic in RUM skews percentiles; filter by user agent.
  • A 200 ms improvement at the origin is invisible if most HTML is served from the CDN; check the HIT ratio first to know which path matters.
  • HTTP→HTTPS and trailing-slash redirects are often added by the platform, not the app; check host/CDN settings, not just application routes.

Verification

  1. Lab: ttfb_breakdown.py before/after from a user region; the dominant phase shrank and no other phase grew. Use --budget in CI against staging to stop regressions.
  2. Server-Timing on the response shows the targeted step reduced.
  3. Cache: second request shows a HIT and plausible age; MISS latency recorded separately.
  4. Field: RUM or CrUX TTFB p75 for the route trends down over the following days (CrUX is a 28-day rolling window — do not expect same-day change).

Output

**Route:** /products/[id]   **Field p75 TTFB:** 1.4 s → target ≤ 0.8 s
| Phase | Before (median) | After | Change |
|---|---|---|---|
| connect+TLS | 180 ms | 35 ms | HTML served from CDN POP |
| server wait (MISS) | 920 ms | 240 ms | 3 sequential API calls → Promise.all; DB moved to app region |
| server wait (HIT) | — | 12 ms | s-maxage=300, stale-while-revalidate=3600 |
**Server-Timing (MISS):** auth 4 ms, db-product 38 ms, pricing-api 160 ms, render 31 ms
**Next:** pricing-api p90 is 400 ms — cache prices per SKU for 60 s

Bundled files

Loaded only when a step in the skill needs them. Review them here before installing.

scripts/ttfb_breakdown.py 164 lines · 6.4 KB
#!/usr/bin/env python3
"""Break TTFB into network and server phases over repeated curl requests (stdlib only).

Runs `curl` N times against a URL (or parses saved output), then prints median and
p90 for DNS, TCP connect, TLS, server wait (request sent -> first byte), and
download, aggregates any Server-Timing header entries, shows cache-related headers,
and names the dominant phase.

Usage:
  python3 ttfb_breakdown.py URL [-n 10] [-H "Cookie: session=..."]
  python3 ttfb_breakdown.py --from saved.txt      # output captured earlier by this script's --raw
  python3 ttfb_breakdown.py URL --raw > saved.txt # save raw curl output for later comparison
  python3 ttfb_breakdown.py URL --budget 600      # exit 1 if median TTFB exceeds 600 ms

Each run is a fresh curl process, so DNS (unless OS-cached), TCP and TLS are paid
every time, like a first visit; server_wait isolates backend time from that. Exit: 0 ok, 1 budget
exceeded or all requests failed, 2 usage error.
"""
import argparse
import re
import shutil
import statistics
import subprocess
import sys

MARK = "__TIMING__"  # must not start with "@": curl reads -w @file
FMT = (MARK + " code=%{http_code} dns=%{time_namelookup} connect=%{time_connect} "
       "tls=%{time_appconnect} pre=%{time_pretransfer} ttfb=%{time_starttransfer} "
       "total=%{time_total} size=%{size_download} ip=%{remote_ip}\\n")
CACHE_HDRS = ("cache-control", "age", "x-cache", "cf-cache-status", "x-vercel-cache",
              "x-nextjs-cache", "cdn-cache-control", "vary", "server", "via", "set-cookie")


def run_curl(url, headers, n):
    if not shutil.which("curl"):
        sys.exit("error: curl not found on PATH")
    chunks = []
    for _ in range(n):
        cmd = ["curl", "-s", "-o", "/dev/null", "-D", "-", "-w", FMT, "--compressed"]
        for h in headers:
            cmd += ["-H", h]
        cmd.append(url)
        res = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
        chunks.append(res.stdout)
    return "".join(chunks)


def parse(raw):
    samples, header_sets = [], []
    current = {}
    for line in raw.splitlines():
        if line.startswith(MARK):
            vals = dict(kv.split("=", 1) for kv in line[len(MARK):].split())
            samples.append(vals)
            header_sets.append(current)
            current = {}
        elif ":" in line and not line.startswith("HTTP/"):
            k, v = line.split(":", 1)
            current.setdefault(k.strip().lower(), []).append(v.strip())
        elif line.startswith("HTTP/"):
            current = {}  # redirects or 100-continue: keep only the final header block
    return samples, header_sets


def phases(s):
    f = {k: float(s[k]) for k in ("dns", "connect", "tls", "pre", "ttfb", "total")}
    tls_end = f["tls"] if f["tls"] > 0 else f["connect"]
    return {
        "dns": f["dns"],
        "tcp": max(f["connect"] - f["dns"], 0),
        "tls": max(tls_end - f["connect"], 0) if f["tls"] > 0 else 0.0,
        "server_wait": max(f["ttfb"] - f["pre"], 0),
        "ttfb": f["ttfb"],
        "download": max(f["total"] - f["ttfb"], 0),
        "total": f["total"],
    }


def pct(values, p):
    v = sorted(values)
    if len(v) == 1:
        return v[0]
    k = (len(v) - 1) * p
    lo = int(k)
    hi = min(lo + 1, len(v) - 1)
    return v[lo] + (v[hi] - v[lo]) * (k - lo)


def server_timing(header_sets):
    agg = {}
    for hs in header_sets:
        for value in hs.get("server-timing", []):
            for entry in value.split(","):
                name = entry.split(";")[0].strip()
                m = re.search(r"dur=([\d.]+)", entry)
                if name and m:
                    agg.setdefault(name, []).append(float(m.group(1)))
    return agg


def main():
    ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
    ap.add_argument("url", nargs="?")
    ap.add_argument("-n", type=int, default=10, help="number of requests (default 10)")
    ap.add_argument("-H", action="append", default=[], help="extra request header")
    ap.add_argument("--from", dest="src", help="parse saved raw output instead of running curl")
    ap.add_argument("--raw", action="store_true", help="print raw curl output and exit")
    ap.add_argument("--budget", type=float, help="median TTFB budget in ms")
    a = ap.parse_args()

    if a.src:
        with open(a.src, encoding="utf-8") as fh:
            raw = fh.read()
    elif a.url:
        raw = run_curl(a.url, a.H, a.n)
        if a.raw:
            print(raw, end="")
            return 0
    else:
        ap.print_usage(sys.stderr)
        return 2

    samples, header_sets = parse(raw)
    ok = [(s, h) for s, h in zip(samples, header_sets) if s.get("code", "000") != "000"]
    if not ok:
        print("error: no successful responses (code 000 = connection failed)", file=sys.stderr)
        return 1
    codes = sorted({s["code"] for s, _ in ok})
    ph = [phases(s) for s, _ in ok]
    print(f"requests: {len(ok)}/{len(samples)} ok  status: {', '.join(codes)}  ip: {ok[-1][0].get('ip', '?')}")
    print(f"{'phase':<13}{'median ms':>10}{'p90 ms':>9}")
    for key in ("dns", "tcp", "tls", "server_wait", "ttfb", "download", "total"):
        vals = [p[key] * 1000 for p in ph]
        print(f"{key:<13}{statistics.median(vals):>10.1f}{pct(vals, 0.9):>9.1f}")

    st = server_timing([h for _, h in ok])
    if st:
        print("\nServer-Timing (median ms):")
        for name, vals in sorted(st.items(), key=lambda kv: -statistics.median(kv[1])):
            print(f"  {name:<20}{statistics.median(vals):>8.1f}  (n={len(vals)})")

    last = ok[-1][1]
    shown = [(k, last[k]) for k in CACHE_HDRS if k in last]
    if shown:
        print("\nCache/edge headers (last response):")
        for k, v in shown:
            print(f"  {k}: {' | '.join(v)}")

    med = {k: statistics.median([p[k] for p in ph]) for k in ph[0]}
    net = med["dns"] + med["tcp"] + med["tls"]
    ttfb_ms = med["ttfb"] * 1000
    print(f"\nmedian TTFB {ttfb_ms:.0f} ms (network setup ~{net * 1000:.0f} ms + server wait ~{med['server_wait'] * 1000:.0f} ms)")
    if med["server_wait"] >= net:
        print("dominant: server wait -> profile the request path (app, DB, upstream calls) or cache the response")
    else:
        print("dominant: connection setup -> serve from an edge/CDN closer to users, enable HTTP/2+/3, check TLS and DNS")
    if a.budget is not None and ttfb_ms > a.budget:
        print(f"FAIL: median TTFB {ttfb_ms:.0f} ms exceeds budget {a.budget:.0f} ms")
        return 1
    return 0


if __name__ == "__main__":
    sys.exit(main())

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc