Skip to Content
LoadingTTFB

Time to first byte

Chrome 57+ Edge 79+ Firefox 58+ Safari 15+ Details

Time to First Byte (TTFB) measures the time from when the user starts navigating to a page until the first byte of the HTML response is received. It’s a critical metric that reflects server responsiveness and network latency.

TTFB includes

  • Redirect time
  • Service worker startup time (if applicable)
  • DNS lookup
  • TCP connection and TLS negotiation
  • Request time until the first byte of the response arrives

Thresholds (according to web.dev)

RatingTTFB
Good≤ 800ms
Needs Improvement800ms - 1800ms
Poor> 1800ms

Measure the time to first byte

Snippet

Run it from an AI agent: the prompt uses the CLI to measure any URL and report the result.

// Measure TTFB with threshold indicator
// https://webperf-snippets.nucliweb.net

(() => {
  new PerformanceObserver((entryList) => {
    const [pageNav] = entryList.getEntriesByType("navigation");
    // Prerendered pages: measure from activation, not from the start of the prerender
    const ttfb = Math.max(0, pageNav.responseStart - (pageNav.activationStart || 0));

    let rating, color;
    if (ttfb <= 800) {
      rating = "Good";
      color = "#22c55e";
    } else if (ttfb <= 1800) {
      rating = "Needs Improvement";
      color = "#f59e0b";
    } else {
      rating = "Poor";
      color = "#ef4444";
    }

    console.log(
      `%cTTFB: ${ttfb.toFixed(0)}ms (${rating})`,
      `color: ${color}; font-weight: bold; font-size: 14px;`
    );
  }).observe({
    type: "navigation",
    buffered: true,
  });

  // Synchronous return for agent
  const [nav] = performance.getEntriesByType("navigation");
  if (!nav) return { script: "TTFB", status: "error", error: "No navigation entry" };
  const value = Math.round(Math.max(0, nav.responseStart - (nav.activationStart || 0)));
  const rating = value <= 800 ? "good" : value <= 1800 ? "needs-improvement" : "poor";
  return { script: "TTFB", status: "ok", metric: "TTFB", value, unit: "ms", rating,
    thresholds: { good: 800, needsImprovement: 1800 } };
})();

Measure TTFB sub-parts

Breaks down TTFB into its component phases to identify where time is being spent. This helps pinpoint whether slowness is due to DNS, TCP connection, SSL negotiation, or server processing.

Based on pagespeed (opens in a new tab) by Arjen Karel (opens in a new tab).

The steps that make up TTFB, in order: redirect, service worker, DNS lookup, TCP connection, TLS handshake and HTTP request until the first byte arrivesSteps: Browser to Browser: Redirect/Wait; Browser to Service Worker: Service Worker/Cache; Service Worker to Browser: Cache miss; Browser to DNS Server: DNS Lookup; DNS Server to Browser: IP Address; Browser to Server: TCP Connection; Browser to Server: SSL/TLS Handshake; Browser to Server: HTTP Request; Server to Browser: First Byte; Note: ← TTFB ends here.Redirect/WaitService Worker/CacheCache missDNS LookupIP AddressTCP ConnectionSSL/TLS HandshakeHTTP RequestFirst Byte← TTFB ends hereBrowserService WorkerDNS ServerServer

Sub-parts explained

PhaseDescriptionCommon causes of delays
RedirectTime spent following HTTP redirectsToo many redirects, redirect chains
Service WorkerSW startup and cache lookup timeComplex SW logic, cache misses
DNSDomain name resolutionNo DNS caching, slow DNS provider
TCPTCP connection establishmentGeographic distance, no connection reuse
SSL/TLSSecure connection negotiationNo TLS session resumption, slow handshake
RequestTime to first byte after connectionSlow server, database queries, no caching
Switch scenarios to see how each sub-part adds to the total, and select a sub-part to highlight it. Open demo in a new tab ↗

Snippet

Run it from an AI agent: the prompt uses the CLI to measure any URL and report the result.

// Measure TTFB sub-parts breakdown
// https://webperf-snippets.nucliweb.net

(() => {
  new PerformanceObserver((entryList) => {
    const [pageNav] = entryList.getEntriesByType("navigation");

    const activationStart = pageNav.activationStart || 0;
    const waitEnd = Math.max((pageNav.workerStart || pageNav.fetchStart) - activationStart, 0);
    const dnsStart = Math.max(pageNav.domainLookupStart - activationStart, 0);
    const tcpStart = Math.max(pageNav.connectStart - activationStart, 0);
    const tcpEnd = Math.max(pageNav.connectEnd - activationStart, 0);
    // secureConnectionStart is 0 on plain HTTP and reused connections: no TLS phase
    const sslStart =
      pageNav.secureConnectionStart > 0
        ? Math.max(pageNav.secureConnectionStart - activationStart, 0)
        : tcpEnd;
    const responseStart = Math.max(pageNav.responseStart - activationStart, 0);

    const formatMs = (ms) => ms.toFixed(2) + " ms";
    const formatBar = (ms, total) => {
      const pct = total > 0 ? (ms / total) * 100 : 0;
      const width = Math.round(pct / 5);
      return "█".repeat(width) + "░".repeat(20 - width) + ` ${pct.toFixed(1)}%`;
    };

    // Rating
    let rating, color;
    if (responseStart <= 800) {
      rating = "Good";
      color = "#22c55e";
    } else if (responseStart <= 1800) {
      rating = "Needs Improvement";
      color = "#f59e0b";
    } else {
      rating = "Poor";
      color = "#ef4444";
    }

    console.group(`%c⏱️ TTFB: ${responseStart.toFixed(0)}ms (${rating})`, `color: ${color}; font-weight: bold; font-size: 14px;`);

    // Sub-parts breakdown
    const subParts = [
      { name: "Redirect/Wait", duration: waitEnd, icon: "🔄" },
      { name: "Service Worker/Cache", duration: dnsStart - waitEnd, icon: "⚙️" },
      { name: "DNS Lookup", duration: tcpStart - dnsStart, icon: "🔍" },
      { name: "TCP Connection", duration: sslStart - tcpStart, icon: "🔌" },
      { name: "SSL/TLS", duration: tcpEnd - sslStart, icon: "🔒" },
      { name: "Server Response", duration: responseStart - tcpEnd, icon: "📥" },
    ];

    console.log("");
    console.log("%cBreakdown:", "font-weight: bold;");
    subParts.forEach(({ name, duration, icon }) => {
      if (duration > 0) {
        console.log(`${icon} ${name.padEnd(20)} ${formatMs(duration).padStart(10)}  ${formatBar(duration, responseStart)}`);
      }
    });

    console.log("");
    console.log(`%c${"─".repeat(60)}`, "color: #666;");
    console.log(`%c📊 Total TTFB${" ".repeat(15)} ${formatMs(responseStart).padStart(10)}`, "font-weight: bold;");

    // Recommendations based on longest phase
    const longestPhase = subParts.reduce((a, b) => (a.duration > b.duration ? a : b));
    if (longestPhase.duration > responseStart * 0.4 && responseStart > 800) {
      console.log("");
      console.log("%c💡 Recommendation:", "color: #3b82f6; font-weight: bold;");
      const tips = {
        "Redirect/Wait": "Minimize redirects. Use direct URLs where possible.",
        "Service Worker/Cache": "Optimize service worker. Consider cache-first strategies.",
        "DNS Lookup": "Use DNS prefetching. Consider a faster DNS provider.",
        "TCP Connection": "Enable HTTP/2 or HTTP/3. Use connection keep-alive.",
        "SSL/TLS": "Enable TLS 1.3. Use session resumption. Check certificate chain.",
        "Server Response": "Optimize server processing. Add caching. Check database queries.",
      };
      console.log(`   ${longestPhase.name} is ${((longestPhase.duration / responseStart) * 100).toFixed(0)}% of TTFB.`);
      console.log(`   ${tips[longestPhase.name]}`);
    }

    console.groupEnd();
  }).observe({
    type: "navigation",
    buffered: true,
  });

  // Synchronous return for agent
  const [navSync] = performance.getEntriesByType("navigation");
  if (!navSync) return { script: "TTFB-Sub-Parts", status: "error", error: "No navigation entry" };
  const activStartSync = navSync.activationStart || 0;
  const ttfbValueSync = Math.max(0, navSync.responseStart - activStartSync);
  const value = Math.round(ttfbValueSync);
  const rating = value <= 800 ? "good" : value <= 1800 ? "needs-improvement" : "poor";
  const waitEndSync = Math.max((navSync.workerStart || navSync.fetchStart) - activStartSync, 0);
  const dnsStartSync = Math.max(navSync.domainLookupStart - activStartSync, 0);
  const tcpStartSync = Math.max(navSync.connectStart - activStartSync, 0);
  const tcpEndSync = Math.max(navSync.connectEnd - activStartSync, 0);
  const sslStartSync =
    navSync.secureConnectionStart > 0
      ? Math.max(navSync.secureConnectionStart - activStartSync, 0)
      : tcpEndSync;
  const subPartsSync = [
    { key: "redirectWait", duration: waitEndSync },
    { key: "serviceWorkerCache", duration: dnsStartSync - waitEndSync },
    { key: "dnsLookup", duration: tcpStartSync - dnsStartSync },
    { key: "tcpConnection", duration: sslStartSync - tcpStartSync },
    { key: "sslTls", duration: tcpEndSync - sslStartSync },
    { key: "serverResponse", duration: ttfbValueSync - tcpEndSync },
  ];
  const slowestSync = subPartsSync.reduce((a, b) => a.duration > b.duration ? a : b);
  return {
    script: "TTFB-Sub-Parts",
    status: "ok",
    metric: "TTFB",
    value,
    unit: "ms",
    rating,
    thresholds: { good: 800, needsImprovement: 1800 },
    details: {
      subParts: Object.fromEntries(subPartsSync.map((p) => [p.key, { value: Math.round(p.duration), unit: "ms" }])),
      slowestPhase: slowestSync.key,
    },
  };
})();

Measure TTFB for all resources

Analyzes TTFB for every resource loaded on the page (scripts, stylesheets, images, fonts, etc.). Helps identify slow third-party resources or backend endpoints.

Note: Each value is the wait between sending the request and receiving the first byte (responseStart - requestStart). Cross-origin resources without a Timing-Allow-Origin header expose no timing; they are excluded from the table and counted in corsRestrictedCount.

Snippet

Run it from an AI agent: the prompt uses the CLI to measure any URL and report the result.

// Measure TTFB for all resources with sorting and summary
// https://webperf-snippets.nucliweb.net

(() => {
  // Domains that belong to the site but differ from the page's root domain, such as its own CDN.
  // They count as first party. Example: const OWN_DOMAINS = ["bbci.co.uk", "bbc.co.uk"];
  const OWN_DOMAINS = [];

  // Waiting time for the first byte of a resource: responseStart is measured from the
  // start of the page, so subtract requestStart. Cross-origin resources without a
  // Timing-Allow-Origin header report 0 for both and cannot be measured.
  const waitTime = (entry) => entry.responseStart - entry.requestStart;
  const hasTiming = (entry) => entry.responseStart > 0;
  // @shared getRootDomain
  function getRootDomain(hostname) {
    const host = hostname.replace(/\.$/, "");
    // An IP address has no registrable domain, so each address is its own root
    if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host) || host.includes(":")) return host;
    const parts = host.split(".");
    if (parts.length <= 2) return host;
    // Country-code domains with a second-level suffix: example.co.uk, example.com.au, example.ac.jp
    const secondLevelSuffixes = ["ac", "co", "com", "edu", "go", "gob", "gouv", "gov", "govt", "mil", "ne", "net", "nom", "or", "org", "sch"];
    const tld = parts[parts.length - 1];
    const sld = parts[parts.length - 2];
    if (tld.length === 2 && secondLevelSuffixes.includes(sld)) return parts.slice(-3).join(".");
    return parts.slice(-2).join(".");
  }
  // @end-shared getRootDomain

  // @shared isFirstParty
  function isFirstParty(hostname) {
    const root = getRootDomain(hostname);
    if (root === getRootDomain(location.hostname)) return true;
    return OWN_DOMAINS.some((d) => getRootDomain(String(d).trim().toLowerCase().replace(/^[a-z]+:\/\//, "").split("/")[0]) === root);
  }
  // @end-shared isFirstParty

  // @shared logOwnDomainsHint
  function logOwnDomainsHint(thirdPartyCount) {
    if (OWN_DOMAINS.length === 0 && thirdPartyCount > 0) {
      console.log(
        '%cℹ️ OWN_DOMAINS is empty. If this site serves its own assets from other domains (for example a CDN), add them at the top of the snippet, such as const OWN_DOMAINS = ["cdn.example.net"];, and run it again so they count as first party.',
        "color: #3b82f6;"
      );
    }
  }
  // @end-shared logOwnDomainsHint

  const isThirdPartyUrl = (name) => {
    try {
      return !isFirstParty(new URL(name).hostname);
    } catch {
      return false;
    }
  };

  new PerformanceObserver((entryList) => {
  const entries = entryList.getEntries();
  const restrictedCount = entries.filter((entry) => !hasTiming(entry)).length;

  const resourcesData = entries
    .filter(hasTiming)
    .map((entry) => {
      const isThirdParty = isThirdPartyUrl(entry.name);

      return {
        ttfb: waitTime(entry),
        duration: entry.duration,
        type: entry.initiatorType,
        thirdParty: isThirdParty,
        resource: entry.name.length > 70 ? "..." + entry.name.slice(-67) : entry.name,
        fullUrl: entry.name,
      };
    })
    .sort((a, b) => b.ttfb - a.ttfb);

  if (resourcesData.length === 0) {
    console.log("%c⚠️ No resources with TTFB data available", "color: #f59e0b;");
    console.log("Resources may be cached or missing Timing-Allow-Origin header.");
    return;
  }

  // Summary statistics
  const ttfbValues = resourcesData.map((r) => r.ttfb);
  const avgTtfb = ttfbValues.reduce((a, b) => a + b, 0) / ttfbValues.length;
  const maxTtfb = Math.max(...ttfbValues);
  const minTtfb = Math.min(...ttfbValues);
  const thirdPartyCount = resourcesData.filter((r) => r.thirdParty).length;
  const slowResources = resourcesData.filter((r) => r.ttfb > 500).length;

  console.group(`%c📊 Resource TTFB Analysis (${resourcesData.length} resources)`, "font-weight: bold; font-size: 14px;");

  // Summary
  console.log("");
  console.log("%cSummary:", "font-weight: bold;");
  console.log(`   Average TTFB: ${avgTtfb.toFixed(0)}ms`);
  console.log(`   Fastest: ${minTtfb.toFixed(0)}ms | Slowest: ${maxTtfb.toFixed(0)}ms`);
  console.log(`   Third-party resources: ${thirdPartyCount}`);
  if (restrictedCount > 0) {
    console.log(`%c   ⚠️ ${restrictedCount} resource(s) not measured: missing Timing-Allow-Origin header`, "color: #f59e0b;");
  }
  if (slowResources > 0) {
    console.log(`%c   ⚠️ Slow resources (>500ms): ${slowResources}`, "color: #f59e0b;");
  }

  // Table (sorted by TTFB, slowest first)
  console.log("");
  console.log("%cResources (sorted by TTFB, slowest first):", "font-weight: bold;");
  const tableData = resourcesData.slice(0, 25).map((resource) => ({
    "TTFB (ms)": resource.ttfb.toFixed(0),
    "Duration (ms)": resource.duration.toFixed(0),
    Type: resource.type,
    "3rd Party": resource.thirdParty ? "Yes" : "",
    Resource: resource.resource,
  }));
  console.table(tableData);

  if (resourcesData.length > 25) {
    console.log(`... and ${resourcesData.length - 25} more resources`);
  }

  // Slowest resources highlight
  const slowest = resourcesData.slice(0, 5);
  if (slowest[0].ttfb > 500) {
    console.log("");
    console.log("%c🐌 Slowest resources:", "color: #ef4444; font-weight: bold;");
    slowest.forEach((r, i) => {
      const marker = r.thirdParty ? " [3rd party]" : "";
      console.log(`   ${i + 1}. ${r.ttfb.toFixed(0)}ms - ${r.type}${marker}: ${r.resource}`);
    });
  }

  logOwnDomainsHint(thirdPartyCount);
  console.groupEnd();
  }).observe({
    type: "resource",
    buffered: true,
  });

  // Synchronous return for agent
  const allResources = performance.getEntriesByType("resource");
  const corsRestrictedCount = allResources.filter((entry) => !hasTiming(entry)).length;
  const resourcesSync = allResources
    .filter(hasTiming)
    .map((entry) => ({
      url: entry.name,
      ttfbMs: Math.round(waitTime(entry)),
      durationMs: Math.round(entry.duration),
      type: entry.initiatorType,
      isThirdParty: isThirdPartyUrl(entry.name),
    }))
    .sort((a, b) => b.ttfbMs - a.ttfbMs);
  if (resourcesSync.length === 0) {
    // Nothing to measure is a valid result, such as a page with every resource inline or hidden by CORS
    return {
      script: "TTFB-Resources",
      status: "ok",
      count: 0,
      corsLimitedAnalysis: corsRestrictedCount > 0,
      details: { avgTtfbMs: null, maxTtfbMs: null, minTtfbMs: null, thirdPartyCount: 0, slowCount: 0, corsRestrictedCount },
      items: [],
      issues: [{ severity: "info", message: "No resources with TTFB data available" }],
    };
  }
  const ttfbVals = resourcesSync.map((r) => r.ttfbMs);
  // The list keeps the 50 slowest resources (already sorted); count and details cover all of them
  const MAX_ITEMS = 50;
  return {
    script: "TTFB-Resources",
    status: "ok",
    count: resourcesSync.length,
    corsLimitedAnalysis: corsRestrictedCount > 0,
    details: {
      avgTtfbMs: Math.round(ttfbVals.reduce((a, b) => a + b, 0) / ttfbVals.length),
      maxTtfbMs: Math.max(...ttfbVals),
      minTtfbMs: Math.min(...ttfbVals),
      thirdPartyCount: resourcesSync.filter((r) => r.isThirdParty).length,
      slowCount: resourcesSync.filter((r) => r.ttfbMs > 500).length,
      corsRestrictedCount,
    },
    items: resourcesSync.slice(0, MAX_ITEMS),
  };
})();

The resources table marks a resource as third party when it does not share the page’s root domain. A site’s own CDN on a different root domain, such as bbci.co.uk for bbc.co.uk, is reported as a third party, because the comparison uses root domains only. To count those domains as first party, list them in the OWN_DOMAINS constant at the top of the snippet, for example const OWN_DOMAINS = ["bbci.co.uk", "bbc.co.uk"];. Entries can be domains or URLs. While the list is empty and the page has third-party resources, the snippet prints a reminder in the console.

Browser support

FeatureChromeEdgeFirefoxSafari
PerformanceNavigationTiming (opens in a new tab) 57 12 58 15
PerformanceObserver (opens in a new tab) 52 79 57 11
All of the above 57 79 58 15

Source: MDN browser compatibility data (opens in a new tab), version 8.1.4.

Further reading

Last updated on
MIT 2026 © Joan León | @nucliweb