載入 14 KB 的 HTML 花了 1128 ms,時間去了哪裡
這個站的首頁是 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 unload | Level 2 沒有時間戳 | 上一頁的 beforeunload 對話框 | |
| unload | unloadEventEnd − unloadEventStart | 0 ms | 上一頁的 unload handler。上一頁跟這一頁同源才有值,不同源是 0 |
| redirect | redirectEnd − redirectStart | 0 ms | 到最終網址之前的 HTTP redirect。Chrome 自己把 http 升級成 https 的不算,我量到的 redirectCount 是 0 |
| App cache | domainLookupStart − fetchStart | 0.3 ms | 原圖叫它 App cache,指的是已經從瀏覽器拿掉的 application cache 檢查,Level 1 的 fetchStart 定義在那個檢查之前。Level 2 的 fetchStart 是 redirect 之後開始 fetch 的時間,這 0.3 ms 在 Level 2 沒有名字 |
| DNS | domainLookupEnd − domainLookupStart | 73 ms | 網域名稱換成 IP |
| TCP | secureConnectionStart − connectStart | 152 ms | TCP 握手,順利的話是一個 round trip。這一次的 152 不是握手的時間,原因見下一節 |
| TLS | connectEnd − secureConnectionStart | 62 ms | 交換憑證。secureConnectionStart 取在 TLS 握手開始之前,純 http 是 0 |
| Request | responseStart − requestStart | 235 ms | 伺服器送出第一個 byte(TTFB)。responseStart 取在收到第一個 byte 之後 |
| Response | responseEnd − responseStart | 0.3 ms | 剩下的 byte 傳完 |
| Parse | domInteractive − responseEnd | 367 ms | HTML parser,加上 parser 停下來等的資源。這個站等的是 <head> 裡三個 stylesheet:後面的 inline script 要等它們載完才能執行,parser 跟著等。有記 resource 時間的那批五次,domInteractive 都落在最後一個 render-blocking stylesheet 載完之後 1 到 29 ms。domInteractive 本身取在 readyState 變成 interactive 之前 |
| Deferred scripts | domContentLoadedEventStart − domInteractive | 0 ms | <script defer> 跑完。DOMContentLoaded 會等 defer 的 script,不等 async 的 |
| DOMContentLoaded | domContentLoadedEventEnd − domContentLoadedEventStart | 0.7 ms | 你的 DOMContentLoaded handler |
| Subresources | domComplete − domContentLoadedEventEnd | 237 ms | 圖片、字型、iframe、async script。load 要等圖片、script、iframe 全部到齊;字型 MDN 沒列,但一個只有字型慢 2 秒的測試頁,load 跟著晚 2 秒 |
| onload | loadEventEnd − loadEventStart | 0.1 ms | 你的 load handler |
有值的十三列互不重疊,相加就是總時間。網路(redirect 到 Response)522 ms,處理(Parse 到 onload)605 ms,再加 startTime 到 fetchStart 之間的 0.6 ms,合計 1128 ms。剩下沒被任何一列涵蓋的是兩個 0.1 ms 的空隙,connectEnd 到 requestStart,還有 domComplete 到 loadEventStart。「處理」這個名字對 Parse 那一列不準:那 367 ms 大半是在等 stylesheet 從網路回來,下一節有數字。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 才有:這個站的伺服器對
http://回 301,但 Chrome 115 起會在送請求之前自己把http://升成https://,2023-08 的公告寫的還是 flag 加 rollout,所以用http://導航,entry 的name已經是https://,redirectCount是 0。 - DNS:沒有任何 cache 有答案。答案查到一次就進作業系統的 resolver cache,之後的載入都是 0,換瀏覽器 profile 也清不掉它;五次 cold start 裡只有第一次是 73 ms。
- TCP:伺服器離得遠。一個 round trip 是下限,程式碼省不掉,CDN 可以讓端點離使用者近一點。這台機器到伺服器 ping 是 42 到 54 ms。表格裡的 152 ms 不是握手的時間:netlog 裡每一次握手都是 46 到 68 ms,那一次是握手完、TLS 進行到一半時 Chrome 換了憑證驗證器(socket 關閉的 reason 是
Cert verifier changed),把所有連線關掉重開,connectStart到secureConnectionStart跨了兩條連線。新 profile 啟動後第一秒會換三到五次驗證器,這是每次開新 Chrome 的量法帶來的,開很久的瀏覽器不會在導航中間碰到。 - TLS:TLS 版本舊,或者連線沒有被重用。HTTP/1.1 預設就把連線留著給下一個 request 用,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 太大,或者沒壓縮。拿
encodedBodySize跟decodedBodySize比(transferSize含 header,不是 body 的大小)。這裡壓縮後 3772 bytes 解開是 14187,壓縮有開。 - Parse:parser 自己不慢,同一份 HTML 從 cache 讀只要 5 到 10 ms(reload 欄),cold start 卻是 198 到 424。差的時間在等 stylesheet:
<head>有三個 stylesheet,一個是跨站的fonts.googleapis.com,<body>裡有 inline<script>,HTML spec 規定 parser 插入的 script 要等還沒載完的 stylesheet,parser 跟著停。有記 resource 時間的那批五次,domInteractive都在最後一個 render-blocking stylesheet 載完之後 1 到 29 ms 出現,跨站那個 stylesheet 自己要一次 DNS 加一條新連線,57 到 138 ms。這個站的 Parse 修法是 stylesheet:inline、preload、或少一個跨站來源。沒有defer也沒有async的外部<script>也會讓 parser 停下來等,這個站沒有。render 不在這張圖上,第一次 paint 的時間戳在performance.getEntriesByType('paint')的first-contentful-paint。這個站三次 cold start,它落在domInteractive之後 4 到 12 ms,因為擋住 parser 的 stylesheet 同時也擋住第一次 paint,兩個一起放行。從 DOM 到像素中間那幾步,style、layout、paint、composite,在另一篇。 - Deferred scripts:
<script defer>在 parse 完之後、DOMContentLoaded之前執行,而且DOMContentLoaded會等它。這個站的 chunk 用async載入,async不會拖住DOMContentLoaded,所以 Deferred scripts 在這裡是 0。要是一個站把大 bundle 標成 defer,bundle 執行的時間就會出現在 Deferred scripts。 - Subresources:頁面在等圖片、字型、iframe、async script 都到齊才發
load。from-setstate-to-pixels 那一頁嵌了兩個 iframe,同源的/embeds/browser-arch跟跨站的specificity.webcore.io,有記 resource 時間的那批五次裡 Subresources 是 487 到 596 ms。Resource Timing 給每個 iframe 一個initiatorType是iframe的 entry,跨站那個的大小欄被遮成 0,時間欄還在:它的responseEnd跟domComplete每次只差 0.2 到 1.7 ms,同源那個早 404 到 735 ms 就好了。那格幾乎整段是別人的伺服器。
五次 cold start,加一次 reload
每次 cold start 都是新開的 headless Chrome 153,新 profile、新 disk cache,導航一次,台北時間下午 12 點到 3 點。reload 是同一個瀏覽器載完首頁後用 DevTools protocol 的 Page.reload 再載一次,entry 的 type 是 reload。文章頁是 /v2/from-setstate-to-pixels。
| 階段 | 首頁,cold start ×5 | 文章頁,cold start ×5 | 首頁,reload ×3 |
|---|---|---|---|
| DNS | 0 到 73 ms | 0 到 1 ms | 0 |
| TCP + TLS | 127 到 214 ms | 115 到 167 ms | 0 |
| Request(TTFB) | 49 到 235 ms | 50 到 244 ms | 49 到 51 ms |
| Response | 0.3 ms | 14 到 19 ms | 0.5 到 1.1 ms |
| Parse | 198 到 424 ms | 221 到 356 ms | 5 到 10 ms |
| Subresources | 125 到 237 ms | 496 到 909 ms | 10 到 17 ms |
| 合計 | 555 到 1128 ms | 939 到 1677 ms | 66 到 81 ms |
| HTML 線上 bytes | 4076 B | 28585 B | 300 B |
reload 欄的 HTML 只傳了 300 bytes,decodedBodySize 還是 14187:300 bytes 只夠放 header,body 從瀏覽器 cache 來。DNS 跟 TCP 是 0,連線重用了;Request 還是 49 到 51 ms,跟 ping 一樣長,是問伺服器一次「這份還新鮮嗎」的來回。Parse 掉到 5 到 10 ms,stylesheet 都在 cache 裡,parser 不用等任何資源。同一欄裡的差距跟欄與欄之間一樣大,首頁的 TTFB 從 49 到 235 ms,中間什麼都沒改。
量測腳本
Node 22、macOS、Chrome 裝在預設路徑。每次都新開一個 Chrome,新 profile、新 disk cache,導航一次,load 之後把 entry 讀出來。reload 那欄用的是同一支腳本加一段:第一次 load 之後送 Page.reload,再讀一次 entry。stylesheet 的時間是另一支同型的腳本,多 dump 一份 performance.getEntriesByType('resource')。字型的測試是一個本機頁面,唯一慢的資源是一個延遲 2 秒的字型檔。
// 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 換了時鐘。Level 1 的值是從 1970 年起算的絕對毫秒,Level 2 改用從 time origin 起算的
DOMHighResTimeStamp,起點恆為 0,理由是絕對時鐘會被系統調整倒退、精度只到毫秒。navigationStart 變成startTime的 0,其他名字沒變。 - domLoading:各家瀏覽器建立 Document 的時間點不同,Chrome 在 response 開始前,IE 在 response 開始時,Firefox 要先 buffer 一段,量到的是實作細節。2015 年 issue #13 提出移除,spec 加了 deprecation note。最接近的值是
responseEnd。 - Prompt for unload:
beforeunload對話框,發生在navigationStart之前,Level 1 把起點定義在「問完要不要離開上一頁之後」,兩版都沒有它的時間戳,圖上畫它只是標出起點前面的動作。
startTime 到 fetchStart 之間這次是 0.6 ms,是導航開始到 fetch 開始的空檔,沒有名字。unload 跟 redirect 有發生的話會落在這段,這次都是 0;redirectStart 是 0 表示沒有 redirect,不是 redirect 花了 0 ms。
參考
- Navigation Timing,W3C Recommendation,2012-12-17。Level 1,原圖的出處,圖檔是 timing-overview.png。
- Navigation Timing Level 2,W3C Working Draft。現在的 spec,processing model 那節定義每個時間戳在哪一刻取。
- PerformanceNavigationTiming,MDN。每個屬性,附一張它們發生順序的圖。
- Performance.timing,MDN。已經 deprecated 的 Level 1 介面,讀舊圖的時候對照用。
- The script element,MDN。沒有屬性、
async、defer三種 script 各在哪一刻執行。 - Towards HTTPS by default,Chromium Blog,2023-08-16。Chrome 自動把 http 升級成 https 的公告。
- Measure the Critical Rendering Path,web.dev。同一組減法,用在第一次 render 上。