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)
| Rating | TTFB |
|---|---|
| Good | ≤ 800ms |
| Needs Improvement | 800ms - 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).
Sub-parts explained
| Phase | Description | Common causes of delays |
|---|---|---|
| Redirect | Time spent following HTTP redirects | Too many redirects, redirect chains |
| Service Worker | SW startup and cache lookup time | Complex SW logic, cache misses |
| DNS | Domain name resolution | No DNS caching, slow DNS provider |
| TCP | TCP connection establishment | Geographic distance, no connection reuse |
| SSL/TLS | Secure connection negotiation | No TLS session resumption, slow handshake |
| Request | Time to first byte after connection | Slow server, database queries, no caching |
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
| Feature | Chrome | Edge | Firefox | Safari |
|---|---|---|---|---|
| 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
- Time to First Byte (TTFB) (opens in a new tab) | web.dev
- Optimize TTFB (opens in a new tab) | web.dev
- Navigation Timing API (opens in a new tab) | MDN
- Resource Timing API (opens in a new tab) | MDN
- FCP | First Contentful Paint, the next metric after TTFB