1128 ms to load 14 KB of HTML. Where did it go?

browserperformancefrontend

The home page of this site is 14 KB of HTML. With its scripts, styles and fonts it is about 300 KB on the wire. A cold start took 1128 ms. Which stage took the time?

The browser already knows. Paste this into the console of any page:

performance.getEntriesByType('navigation')[0]

It returns one object. Here is the one from that cold start, trimmed to the timestamps, all in milliseconds from the moment the navigation started:

{
  fetchStart:                   0.6,
  domainLookupStart:            0.9,
  domainLookupEnd:             74.2,
  connectStart:                74.2,
  secureConnectionStart:      226.2,
  connectEnd:                 287.9,
  requestStart:               288.0,
  responseStart:              522.7,
  responseEnd:                523.0,
  domInteractive:             889.9,
  domContentLoadedEventStart: 889.9,
  domContentLoadedEventEnd:   890.6,
  domComplete:               1127.6,
  loadEventStart:            1127.7,
  loadEventEnd:              1127.8,
  duration:                  1127.8,
  transferSize:                4076,
  decodedBodySize:            14187,
  nextHopProtocol:             "h2",
}

This object follows the W3C spec Navigation Timing Level 2 (Working Draft, this revision dated 2026-09-01); the API is PerformanceNavigationTiming. The Navigation Timing diagram redrawn in the iframe below comes from the first version, Level 1 (W3C Recommendation, 2012-12-17), in its Processing Model section; its API is performance.timing, now marked deprecated on MDN. Most field names are the same in both. The differences are where the clock starts and a few fields Level 2 dropped, listed at the end.

domainLookupEnd − domainLookupStart = 74.2 − 0.9 = 73 ms, the time DNS took. Every other stage is the same kind of subtraction between its two ends. Every box of the original diagram is below, including the ones that were 0 this time:

StageSubtractionThis loadWhat the browser is waiting for
Prompt for unloadno Level 2 timestampthe previous page's beforeunload dialog
unloadunloadEventEnd − unloadEventStart0 msthe previous page's unload handlers
redirectredirectEnd − redirectStart0 msHTTP redirects before the final URL
App cachedomainLookupStart − fetchStart0.3 msthe original diagram's App cache is the application cache check, since removed from browsers; Level 1 defines fetchStart as the moment before that check. Level 2's fetchStart is the post-redirect start of the fetch; these 0.3 ms have no name in Level 2
DNSdomainLookupEnd − domainLookupStart73 msthe domain name to become an IP address
TCPsecureConnectionStart − connectStart152 msthe TCP handshake, one round trip when nothing goes wrong. This load's 152 is not handshake time, see the next section
TLSconnectEnd − secureConnectionStart62 msthe certificate exchange. secureConnectionStart is taken just before the TLS handshake, 0 on plain http
RequestresponseStart − requestStart235 msthe server to send the first byte (TTFB). responseStart is taken right after the first byte arrives
ResponseresponseEnd − responseStart0.3 msthe rest of the bytes
ParsedomInteractive − responseEnd367 msthe HTML parser, plus whatever the parser stops to wait for. domInteractive is taken just before readyState becomes interactive
Deferred scriptsdomContentLoadedEventStart − domInteractive0 ms<script defer> to run
DOMContentLoadeddomContentLoadedEventEnd − domContentLoadedEventStart0.7 msthe page's DOMContentLoaded handlers
SubresourcesdomComplete − domContentLoadedEventEnd237 msimages, fonts, iframes, async scripts
onloadloadEventEnd − loadEventStart0.1 msthe page's load handlers

The thirteen rows with a value do not overlap, and together they cover almost the whole load: 522 ms from redirect through Response, 605 ms from Parse through onload, plus 0.6 ms between startTime and fetchStart, 1128 ms in total. The only time no row covers is two gaps of 0.1 ms each, connectEnd to requestStart and domComplete to loadEventStart. Most of Parse's 367 ms is spent waiting for stylesheets to come back over the network, with numbers in the next section. The 14 KB of HTML itself took only 0.3 ms to arrive (the Response row).

That was one load on my machine. Below is the Navigation Timing diagram itself, all ten boxes and all twenty one timestamps, with the numbers replaced by the load you are looking at right now, read from this page's own entry. The diagram's Processing box covers the table's four rows from Parse to Subresources. This iframe is itself one of the page's subresources, so Processing and Subresources both include the time it took to load the iframe:

Each stage's blocking issue

  • unload, redirect, App cache: always 0 on this site. unload is set only when the previous page was on the same origin, 0 otherwise. redirect is 0 because since Chrome 115 the browser upgrades http:// to https:// before sending the request, so the server's 301 for http:// is never followed.
  • DNS: the first lookup took 73 ms. After that the answer stays in the operating system's resolver cache, which a fresh browser profile does not clear, so only the first of five cold starts was not 0.
  • TCP: a handshake needs at least one round trip, and ping from this machine to the server is 42 to 54 ms. No code removes that; only a CDN moves the endpoint closer. The 152 ms in the table is an exception: halfway through TLS on that load, Chrome swapped its certificate verifier and reopened the connection, so it measured two connections. In a Chrome netlog recorded separately (a log of the connection steps), each single handshake took 46 to 68 ms. Only starting a new Chrome for every measurement runs into this.
  • TLS: a TLS 1.2 handshake takes two round trips and TLS 1.3 takes one; a connection that is not reused is also slow. HTTP/1.1 keeps connections open by default and HTTP/2 and HTTP/3 multiplex one connection, so TLS usually happens once per origin.
  • Request: the server handling the request. For a static file, a long TTFB usually means the CDN had no cached copy; for server rendering, the time mostly goes to the render function. Rendering strategy mainly affects this stage.
  • Response: the HTML is large, or not compressed. Compare encodedBodySize with decodedBodySize (transferSize includes headers, so it does not work for this). Here encodedBodySize is 3772 and it decodes to 14187, so compression is on.
  • Parse: the same HTML takes 5 to 10 ms on reload and 198 to 424 ms on a cold start; the difference is all waiting for stylesheets. <body> has inline <script> elements, and the HTML spec makes them wait for stylesheets in <head> that are still loading, so the parser stops too. Five more cold starts with a script that also records each resource's timing put domInteractive 1 to 29 ms after the last stylesheet finished, every time. One of the three stylesheets is cross-site, fonts.googleapis.com, and it alone costs an extra DNS lookup plus a new connection, 57 to 138 ms. So a faster Parse on this site means inlining the stylesheets, preloading them, or dropping the cross-site origin.
  • Deferred scripts: <script defer> runs after parsing and before DOMContentLoaded, and DOMContentLoaded waits for it. This site loads its chunks with async, which does not delay DOMContentLoaded, so this is 0 here. If a page loads <script defer src="app.js"> and app.js takes 300 ms to run, those 300 ms show up in Deferred scripts.
  • Subresources: load waits for images, scripts and iframes. MDN does not list fonts, but they count: on a test page whose only slow resource is a 2 s font, load came 2 s later. The article page from-setstate-to-pixels embeds a cross-site iframe. Across five runs of the script that records resource timing this stage took 487 to 596 ms, nearly all of it waiting for that iframe: domComplete came 0.2 to 1.7 ms after the iframe finished.

Five cold starts, and one reload

Each cold start is a fresh headless Chrome 153, fresh profile, fresh disk cache, one navigation, between noon and 3 pm Taipei time. The reload is the same browser loading the home page and then loading it again through the DevTools protocol's Page.reload; the entry's type is reload. The article page is /v2/from-setstate-to-pixels.

StageHome page, cold start ×5Article page, cold start ×5Home page, reload ×3
DNS0 to 73 ms0 to 1 ms0
TCP + TLS127 to 214 ms115 to 167 ms0
Request (TTFB)49 to 235 ms50 to 244 ms49 to 51 ms
Response0.3 ms14 to 19 ms0.5 to 1.1 ms
Parse198 to 424 ms221 to 356 ms5 to 10 ms
Subresources125 to 237 ms496 to 909 ms10 to 17 ms
Total555 to 1128 ms939 to 1677 ms66 to 81 ms
HTML transferred (transferSize)4076 B28585 B300 B

On reload the HTML moved only 300 bytes while decodedBodySize stayed at 14187: 300 bytes is room for headers, and the body came from the browser cache. DNS and TCP are 0 because the connection was reused; Request is still 49 to 51 ms, the same as a ping, one round trip asking the server whether the copy is still fresh. TTFB on the home page went from 49 to 235 ms over five runs with nothing changed; on the article page it was 50 to 244 ms. Five runs of the same page spread about as wide as switching pages.

The measurement script

Node 22, macOS, Chrome installed at the default path. The script reads the entry after load. The reload column uses the same script plus one step: after the first load, send Page.reload and read the entry again. The stylesheet timings come from a sibling script that also dumps performance.getEntriesByType('resource'). The font test is a local page whose only slow resource is a font file delayed by 2 s.

// node cold.mjs <url> <runs>
import { spawn } from 'node:child_process'
import { mkdtempSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
 
const [url, runsArg] = process.argv.slice(2)
const sleep = ms => new Promise(r => setTimeout(r, ms))
 
async function once(port) {
  const profile = mkdtempSync(join(tmpdir(), 'chrome-prof-'))
  const cache = mkdtempSync(join(tmpdir(), 'chrome-cache-'))
  const chrome = spawn('/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', [
    '--headless=new', `--remote-debugging-port=${port}`,
    `--user-data-dir=${profile}`, `--disk-cache-dir=${cache}`,
    '--no-first-run', 'about:blank',
  ], { stdio: 'ignore' })
 
  let targets
  for (let i = 0; i < 40; i++) {
    await sleep(250)
    try { targets = await (await fetch(`http://127.0.0.1:${port}/json`)).json(); break } catch {}
  }
  const ws = new WebSocket(targets.find(t => t.type === 'page').webSocketDebuggerUrl)
  await new Promise(r => ws.onopen = r)
  let id = 0
  const pending = new Map()
  ws.onmessage = e => { const m = JSON.parse(e.data); if (pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id) } }
  const send = (method, params = {}) => new Promise(r => { pending.set(++id, r); ws.send(JSON.stringify({ id, method, params })) })
 
  await send('Page.enable')
  await send('Page.navigate', { url })
  let entry
  for (let i = 0; i < 120; i++) {
    await sleep(250)
    const r = await send('Runtime.evaluate', { returnByValue: true,
      expression: `JSON.stringify(performance.getEntriesByType('navigation')[0]?.toJSON() ?? null)` })
    entry = JSON.parse(r.result.result.value)
    if (entry && entry.loadEventEnd > 0) break
  }
  ws.close()
  chrome.kill()
  return entry
}
 
const all = []
for (let i = 0; i < Number(runsArg || 5); i++) all.push(await once(9400 + i))
console.log(JSON.stringify(all))

Which Level 1 timestamps did Level 2 drop

The gap between startTime and fetchStart has no name. unload and redirect would fall in that span if they happened, and both were 0 here; a redirectStart of 0 means there was no redirect, not that a redirect took 0 ms.

References