前端效能指標深入解析:INP、LCP、TTFB、SSR 與 ISR 的真實關係
從 INP 替換 FID 開始,拆解 LCP 四層架構、TTFB 完整路徑,再到 SSR/ISR/Static SPA 的取捨——搞懂每個指標背後在量什麼,才能針對問題下手。
前陣子在深入研究 K-Line 專案的效能優化時,碰到一個困惑:Lighthouse 給不出 INP 的分數,而 SSR 的 TTFB 明顯比 Static SPA 長,但有人說 SSR 的 LCP 反而更好。這兩件事乍看矛盾,但理解之後其實很合理。這篇文章從這兩個困惑出發,把幾個彼此有關係的指標和架構選擇梳理清楚。
FID 退役,INP 接班——為什麼這個替換很重要
Google 在 2024 年 3 月把 INP(Interaction to Next Paint) 正式納入 Core Web Vitals,同時讓 FID(First Input Delay)退役。
FID 量的是:使用者第一次互動(點擊、鍵盤輸入、tap)到瀏覽器開始處理這個事件之間的延遲。問題在於「第一次」——它只量一個點,整個瀏覽過程中後續所有互動的反應速度完全不納入評估。
INP 的設計不同。它量的是整個 session 裡所有互動的 next paint 延遲,取最壞情況(排除少數極端值後的最高值)。也就是說,哪怕你的 FID 很快,只要使用者在操作途中踩到一個慢的互動,INP 就會揭露它。
| 指標 | 量什麼 | 問題 |
|---|---|---|
| FID | 第一次互動的事件處理延遲 | 只量一次,看不到後續互動的問題 |
| INP | 所有互動 → 畫面更新的端對端延遲 | 更貼近使用者的實際感受 |
INP 的閾值:Good ≤ 200ms,Poor > 500ms。超過 200ms,使用者開始感覺卡頓。
INP 沒辦法用 Lighthouse 測
這是第一個重要的認知差距:Lighthouse 跑出來的報告沒有 INP 分數。
原因是 INP 需要真實的使用者互動才能觸發——它必須在真正有人在點擊、滾動、輸入的環境下測量。Lighthouse 是 synthetic 測試,它模擬頁面載入過程,但不模擬使用者互動序列。
要取得 INP 資料,有兩條路:
- Google Analytics 4 + web-vitals library:在 production 環境收集真實使用者的 INP,在 GA4 裡查看 Core Web Vitals 報告。
- Chrome DevTools Performance panel:手動操作頁面,在 Performance 錄製中觀察 interaction 事件的延遲。
LCP 不只是「圖片載慢了」——四層拆解
LCP(Largest Contentful Paint) 是頁面上最大的可見元素完成渲染的時間點。但「LCP 太慢」可以來自四個完全不同的地方:
[navigate] → TTFB → [blocking] → [element load] → [render] = LCP
| 層 | 定義 | 修法方向 |
|---|---|---|
| TTFB | 請求送出 → 收到第一個 byte | CDN、server latency |
| Blocking | HTML 收到 → LCP element 開始 fetch | 移除 render-blocking scripts / fonts |
| Element load | LCP element fetch 開始 → 完成 | 壓縮圖片、加 preload |
| Render | Fetch 完成 → 畫在螢幕上 | JS 阻塞 main thread(INP 的領域) |
這四層要分開看。如果 TTFB 本身就長(比如 SSR 沒有 cache),就算後三層都很快,LCP 還是起跑晚。如果 element load 很慢(大張 PNG 沒有壓縮),加再多 preload 也只是「更早開始等」,不是真正快了。
混在一起看只會把不同問題的解法搞混。舉個例子:上一篇 K-Line 優化文章裡,LCP element(hero image)從 3.1s 降到 0.8s,主要靠的是 Element load 層的優化(PNG → WebP,-59% 大小),加上 Blocking 層的優化(self-hosted fonts 移除 render-blocking stylesheet)。這兩層改了,分數才有感。
TTFB 的完整路徑:從 DNS 到第一個 byte
TTFB(Time to First Byte) 量的不只是「server 回應速度」,它包含整個網路旅程:
DNS lookup → TCP handshake → TLS handshake → server processing → first byte
每一段都可能是瓶頸。
Static SPA on CDN vs SSR without cache
最容易說明 TTFB 差異的對比就是這兩種架構:
Static SPA on CDN:
- 使用者請求
/→ CDN edge node 直接回傳 pre-built 的index.html - Server 不需要執行任何程式碼,純粹回傳靜態檔案
- TTFB 非常短,通常 < 100ms
SSR without cache:
- 使用者請求
/→ server 必須先執行 React render(或 Next.js 的getServerSideProps)產生 HTML - HTML 產生完才開始回傳,第一個 byte 要等 server 執行完
- TTFB 可能到數百 ms 甚至更長,視 server 的 loading 和資料查詢速度
TTFB 的 Good/Poor 門檻:Good ≤ 800ms,Poor > 1800ms。
SSR vs Static SPA:TTFB 短不代表使用者看到內容快
這是最容易誤解的部分。TTFB 短,不代表 LCP 就好。
Static SPA 的陷阱:空殼 + JS hydration
Static SPA 的 index.html 大概長這樣:
<body>
<div id="root"></div> <!-- 空的 -->
<script src="/main.js"></script>
</body>
TTFB 極短,但 HTML 本身沒有內容——是個空殼。瀏覽器收到之後,還要:
- 下載 JS bundle(可能幾百 KB)
- 執行 JS,React render 出 DOM
- 才有 LCP element 可以渲染
這個 JS 下載 + 執行的時間,就是 LCP 的真正成本。TTFB 短,但 LCP 可能很慢。
SSR 的反直覺優勢
SSR 的 index.html 直接包含 render 好的 HTML:
<body>
<div id="root">
<h1>K-Line Prediction</h1>
<img src="/hero-shot.webp" /> <!-- LCP element,已經在 HTML 裡 -->
...
</div>
</body>
TTFB 比 Static SPA 長(server 要 render),但 HTML 送達後,瀏覽器馬上可以看到 LCP element,不需要等 JS 執行。結果:
| 架構 | TTFB | 使用者看到內容(LCP) |
|---|---|---|
| Static SPA | 短(CDN) | 慢(等 JS hydration) |
| SSR(無 cache) | 長(server render) | 快(HTML 已有內容) |
| SSR(有 cache / ISR) | 短 | 快 |
所以「SSR 比較慢」這個說法是不完整的——TTFB 確實比較長,但 LCP 可以更好,使用者實際感受到的「頁面出現了」反而更快。
ISR:靜態的速度 + 動態的資料
ISR(Incremental Static Regeneration) 是 Next.js 的功能,試圖結合 Static SPA 的短 TTFB 和 SSR 的新鮮資料。
stale-while-revalidate 的運作方式
ISR 的核心概念是 stale-while-revalidate:
- 第一次有人請求某個頁面 → 靜態生成 HTML,存在 CDN
- 後續請求直接從 CDN 回傳靜態頁面(TTFB 極短)
- 超過
revalidate設定的秒數後,下一個請求觸發背景重新生成 - 重新生成完成後,後續請求才拿到新版本
// Next.js App Router
export const revalidate = 86400; // 每 24 小時重新生成一次
按需再生成,不是 cron
這個設計細節很重要:ISR 的再生成是由請求觸發的,不是定時排程。
沒有流量 → 不觸發再生成 → 不消耗資源。對於低流量頁面或長尾頁面,這個設計非常省成本。相比之下,如果是 cron job 定時重新 build 整站,就算沒有人在看某個頁面,資源還是在跑。
revalidate 值怎麼選
revalidate 值應該對應資料的更新頻率。以 K-Line 日 K 資料為例:
- 日 K 每天更新一次(台灣時間收盤後)
revalidate = 86400(24 小時)合理,不會有人看到超過一天的舊資料- 如果資料每小時更新,就設
3600
ISR 的死角
有一個無法避免的 tradeoff:每個 revalidate 週期過後的第一個訪客,一定會看到舊資料。
流程:
- revalidate 時間到了
- 第一個訪客請求頁面 → 觸發背景再生成,但這個訪客拿到的是舊版本
- 再生成完成後 → 第二個訪客才拿到新版本
這個 staleness 對大多數內容型頁面(部落格、產品介紹)是可以接受的。但對即時性要求高的資料,ISR 不夠用。
什麼情況 ISR 不適合
股票即時價格、剛下的訂單狀態、需要立刻反映的使用者資料——這些都不適合 ISR。ISR 的 staleness 是設計上的 tradeoff,不是 bug 可以修掉的問題。
即時資料的正確姿勢
對於需要即時資料的頁面,正確的 pattern 不是「用 SSR 每次都重新 render」,而是分層處理:
SSR 或 ISR 管頁面結構(HTML、SEO 內容、靜態部分)→ WebSocket 或 polling 在 client 管即時價格
// 頁面結構由 ISR 處理(快 TTFB)
export const revalidate = 3600;
// 即時價格在 client 端另外處理
function StockPrice({ ticker }: { ticker: string }) {
const [price, setPrice] = useState<number | null>(null);
useEffect(() => {
const ws = new WebSocket(`wss://api.example.com/price/${ticker}`);
ws.onmessage = (e) => setPrice(JSON.parse(e.data).price);
return () => ws.close();
}, [ticker]);
return <span>{price ?? "—"}</span>;
}
K-Line 預測結果為什麼 client-side fetch 就夠
K-Line 的預測結果是 user-triggered:使用者點擊「查詢」,backend 才跑相似度計算,回傳結果。這個結果:
- 不需要在頁面載入時就存在(不影響 LCP)
- 不需要 SEO 索引
- 每次查詢的結果因使用者輸入而異
這三個條件都指向同一個結論:client-side fetch 就夠了,不需要 SSR 或 ISR。強行用 SSR 反而增加 server loading,而且使用者還是要等查詢完成才有結果——SSR 的優勢完全用不上。
小結
| 指標 / 架構 | 量什麼 | 重點 |
|---|---|---|
| INP | 所有互動 → next paint 端對端 | Lighthouse 測不到,需要 real user monitoring |
| LCP | 最大可見元素完成渲染 | 四層各有不同修法,不要混在一起看 |
| TTFB | 第一個 byte 到達時間 | CDN static = 短;SSR no-cache = 長 |
| Static SPA | TTFB 短,LCP 慢 | 空殼 + JS hydration 是真正成本 |
| SSR | TTFB 長,LCP 可能更好 | HTML 送達時已有內容,不需等 JS |
| ISR | TTFB 短 + 資料定期刷新 | stale-while-revalidate;即時資料不適用 |
Web performance 的指標很多,容易讓人覺得「把每個分數都提高」就是目標。但更重要的是搞清楚每個指標在量什麼、它反映的是哪一層的問題。對症下藥,才不會把時間花在不影響使用者體驗的地方。