載入 14 KB 的 HTML 花了 1128 ms,時間去了哪裡

browserperformancefrontend

這個站的首頁是 14 KB 的 HTML。連 script、CSS、字型一起算,線上傳輸大約 300 KB。cold start 載入一次花了 1128 ms。哪一段花掉的?

瀏覽器有記錄。隨便開一個頁面,在 console 貼這行:

performance.getEntriesByType('navigation')[0]

回來的是一個物件。下面是那次 cold start 的結果,只留時間戳,單位是毫秒,從導航開始算起:

{
  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",
}

這個物件的格式來自 W3C 的 Navigation Timing Level 2(Working Draft,2026-09-01 這版),API 名字是 PerformanceNavigationTiming。下面 iframe 重畫的那張 Navigation Timing 原圖來自第一版 Level 1(W3C Recommendation,2012-12-17),圖檔在 spec 的 Processing Model 那節,API 是 performance.timing,MDN 已經標 deprecated。兩版的欄位名字大多一樣,差在起點怎麼算,還有 Level 2 少了幾個欄位,文末有對照。

這些名字不用背。它們兩兩成對,每一對就是一個階段的頭和尾。unload 跟 redirect 這次都是 0,App cache 是 0.3 ms(fetchStart 0.6 到 domainLookupStart 0.9),寬度畫不出來,所以從 DNS 開始畫:

0.9      74       226     288      523              890               1128
|  DNS   |  TCP   |  TLS  | Request |   parse HTML   |  subresources   |
^        ^        ^       ^         ^                ^                 ^
lookup   connect  secure  request   responseStart    domInteractive    domComplete
Start    Start    Conn.   Start     responseEnd      DCL start + end   loadEventEnd
                  Start             (差 0.3 ms)      (差 0.7 ms)

尾減頭,就是那個階段等了多久。原圖的每個格子都在下面,包括這次是 0 的:

階段減法這次瀏覽器在等什麼
Prompt for unloadLevel 2 沒有時間戳上一頁的 beforeunload 對話框
unloadunloadEventEnd − unloadEventStart0 ms上一頁的 unload handler。上一頁跟這一頁同源才有值,不同源是 0(MDN
redirectredirectEnd − redirectStart0 ms到最終網址之前的 HTTP redirect。Chrome 自己把 http 升級成 https 的不算,我量到的 redirectCount 是 0
App cachedomainLookupStart − fetchStart0.3 ms查詢 HTTP cache。fetchStart 取在查 cache 之前(spec),命中的話 fetch 到這裡就結束
DNSdomainLookupEnd − domainLookupStart73 ms網域名稱換成 IP
TCPsecureConnectionStart − connectStart152 ms一個 round trip,把 socket 開起來
TLSconnectEnd − secureConnectionStart62 ms交換憑證。secureConnectionStart 取在 TLS 握手開始之前,純 http 是 0(MDN
RequestresponseStart − requestStart235 ms伺服器送出第一個 byte(TTFB)。responseStart 取在收到第一個 byte 之後(MDN
ResponseresponseEnd − responseStart0.3 ms剩下的 byte 傳完
ParsedomInteractive − responseEnd367 msHTML parser,以及每一個讓 parser 停下來的 script。domInteractive 取在 readyState 變成 interactive 之前(MDN
Deferred scriptsdomContentLoadedEventStart − domInteractive0 ms<script defer> 跑完。DOMContentLoaded 會等 defer 的 script,不等 async 的(MDN
DOMContentLoadeddomContentLoadedEventEnd − domContentLoadedEventStart0.7 ms你的 DOMContentLoaded handler
SubresourcesdomComplete − domContentLoadedEventEnd237 ms圖片、字型、iframe、async script。load 要等這些全部到齊(MDN
onloadloadEventEnd − loadEventStart0.1 ms你的 load handler

有值的十三列互不重疊,相加就是總時間。網路(redirect 到 Response)522 ms,處理(Parse 到 onload)605 ms,再加 startTime 到 fetchStart 之間的 0.6 ms,合計 1128 ms。14 KB 的 HTML 本身是 Response,0.3 ms。

上面的數字是我的電腦載入一次的結果。下面這張是 Navigation Timing 的原圖,十個格子跟二十一個時間戳都在,只是數字換成你現在看的這一頁自己的載入,從這一頁自己的 entry 讀出來。這個 iframe 本身也是這一頁的 subresource,所以 Processing 跟 Subresources 都把載入 iframe 的時間算進去了:

每個階段的 blocking issue

每一段慢的原因不同,修法也不同。

  • unload、redirect、App cache:這個站上一直是 0。unload 只有上一頁同源才有值,redirect 要真的經過 HTTP 301 或 302 才有,App cache 是查詢 HTTP cache 的時間,命中的話後面 DNS 到 Response 全部是 0。Chrome 從 2023 年起會自己把 http:// 升級成 https://Chromium Blog),我用 http:// 量這個站,redirectCount 還是 0。
  • DNS:沒有任何 cache 有答案。我跑了五次 cold start,每次都是新的瀏覽器 profile 加新的 disk cache。DNS 第一次 73 ms,後四次 0。答案第一次查到之後就進了作業系統的 resolver cache,換瀏覽器 profile 清不掉作業系統的 resolver cache。
  • TCP:伺服器離得遠。一個 round trip 是下限,程式碼省不掉,CDN 可以讓端點離使用者近一點。
  • TLS:TLS 版本舊,或者連線沒有被重用。HTTP/1.1 預設就把連線留著給下一個 request 用(MDN),HTTP/2 和 HTTP/3 一條連線多工,所以 TLS 每個 origin 出現一次,不是每一頁一次。
  • Request:伺服器在工作。靜態檔的話是 CDN cache miss,server render 的話是你的 render function。rendering strategy 改變的就是 Request。static export 加 CDN 讓 Request 變小,server render 讓 Request 變成整排裡最長的。
  • Response:HTML 太大,或者沒壓縮。拿 transferSizedecodedBodySize 比一下。這裡線上 4 KB 解開是 14 KB,壓縮有開。
  • Parse:同步 script 太多。沒有 defer 也沒有 async<script> 會讓 parser 停下來,等它下載完、跑完(MDN)。Parse 結束在 domInteractive,React 也要等到 domInteractive 才能 mount,所以 Parse 多長,app 就多晚開始。接下來從 DOM 到像素的部分,另一篇在講。
  • Deferred scripts<script defer> 在 parse 完之後、DOMContentLoaded 之前執行,而且 DOMContentLoaded 會等它(MDN)。這個站的 chunk 用 async 載入,async 不會拖住 DOMContentLoaded,所以 Deferred scripts 在這裡是 0。要是一個站把大 bundle 標成 defer,bundle 執行的時間就會出現在 Deferred scripts。
  • Subresources:頁面在等圖片、字型、iframe、async script 都到齊才發 load。這個站的文章頁五次 cold start 裡,Subresources 是 496 到 909 ms,因為文章頁嵌了兩個 iframe,load 兩個都要等。

五次 cold start,加一次 reload

每次 cold start 都是我筆電上新開的 headless Chrome 153,新 profile、新 disk cache,導航一次。reload 是同一個瀏覽器裡,緊接著再對同一個網址導航一次。

階段首頁,cold start ×5文章頁,cold start ×5首頁,reload
DNS0 到 73 ms0 到 1 ms0
TCP + TLS124 到 214 ms115 到 167 ms0
Request(TTFB)49 到 235 ms50 到 244 ms0.1 ms
Response0.3 ms14 到 19 ms0.3 ms
Parse198 到 424 ms221 到 356 ms6 ms
Subresources125 到 237 ms496 到 909 ms7 ms
合計555 到 1128 ms939 到 1677 ms14 ms
HTML 線上 bytes4076 B28585 B0

reload 欄的 transferSize 是 0,MDN 說 transferSize 是 0 而 decodedBodySize 不是 0,就代表從本機 cache 拿的(MDN)。HTML 沒走網路,DNS 到 Response 全部是 0,整次載入 14 ms。文章頁的 TCP 和 TLS 跟首頁差不多,多出來的時間都在 Subresources,在等它的 iframe。同一欄裡面的差距跟欄與欄之間的差距一樣大,首頁的 TTFB 從 49 ms 到 235 ms,中間什麼都沒改,只是換了一次跑。跑一次知道哪一段大,跑五次才知道欄與欄的差距有沒有超過雜訊。

量測腳本

Node 22、macOS、Chrome 裝在預設路徑。每次都新開一個 Chrome,新 profile、新 disk cache,導航一次,load 之後把 entry 讀出來。

// 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))

Level 1 有三個事件在 Level 2 沒有

  • navigationStart:原圖最左邊的起點。Level 2 的 spec 在這個屬性下面直接寫「PerformanceNavigationTiming 沒有定義它,改用 timeOrigin」(spec)。原因是 Level 2 換了時鐘。Level 1 的 performance.timing 每個值都是從 1970 年算起的絕對毫秒數,要知道一段花多久,得先拿兩個值相減。Level 2 改用 HR-TIME 的 DOMHighResTimeStamp,每個值都是從 time origin 起算的相對時間,time origin 就是導航開始那一刻(hr-time)。W3C 給的理由有兩個:Date.now() 那種絕對時間在系統時鐘被調整時會倒退,精度也只到毫秒;相對時間用單調時鐘,不會倒退,有毫秒以下的精度。所以 navigationStart 沒有消失,它變成 startTime 的 0,其他名字沒變。
  • domLoading:Level 1 用來標 parser 開始的時刻。2015 年 w3c/navigation-timing 的 issue #13 提出拿掉:Chrome 在 response 開始之前就建立 Document,IE 在 response 開始的時候,Firefox 要先 buffer 一段 response 才建,所以 domLoading 量到的是各家瀏覽器內部實作的時間點,對頁面作者沒有用。結果是 spec 加了一條 deprecation note,「因為各 user agent 建立 Document 的時間點不同,domLoading 的值是 implementation specific,不應該用在有意義的指標上」(spec),PerformanceNavigationTiming 就沒放它。最接近的值是 responseEnd
  • Prompt for unload:離開上一頁之前跳出來的 beforeunload 對話框。Level 1 把 navigationStart 定義在「問完要不要離開上一頁之後」(spec),Level 2 的 time origin 也是導航開始那一刻,對話框在時鐘開始之前,所以兩版都沒有它的時間戳。Level 1 的圖畫這個格子,只是標出起點前面發生了什麼。

fetchStart 本身取在查詢 HTTP cache 之前,startTimefetchStart 之間是 unload 跟 redirect,這次是 0.6 ms。

參考