我們公司的健康2.0 是一個以文章為主的內容網站,用 Next.js App Router 做的。九月初,它的文章頁在 Sentry(我們用來收錯誤和效能資料的服務)收到的真人資料裡,INP 是 358 毫秒。INP 大概可以理解成「手指點下去,到畫面真的有反應」中間那段延遲,Google 的門檻是 200 毫秒,Search Console(網站主看搜尋表現的後台)也把文章頁整批標成不及格。

先講我們的情況:這個站用 Next.js,有獨立的後端,自己架,大部分頁面都被 CDN 快取。結論也先講:網路上那些 Next.js 效能比較,沒有一份測的是這種站。我們自己在實驗室量的數字,一開始也跟真人對不起來,後來才發現是條件差太多。

從 Sentry 把真人的點擊拉出來看,最奇怪的是兩種:點文章標題、點內文段落。這兩個地方根本沒有掛任何事件,點下去卻要等 440 到 608 毫秒(p75,也就是四分之三的點擊在這個數字以內)。

沒有事件還要等,代表瀏覽器那時候正在忙別的事。我們猜是 hydration:HTML 先送到瀏覽器,JavaScript 再接上去讓畫面能按。我們的文章頁會在瀏覽器裡把整篇內文重新解析成 React 元素,再一個一個接上去,一篇文章有兩三千個元素。

修法是把解析搬到 server,而且只有會互動的小塊(影片、問卷、廣告版位)做成 React 元件,其他整段照原文當 HTML 輸出。只把解析搬過去還不夠:那樣 RSC payload(Next.js 送給瀏覽器的元件資料)會帶著整棵元素樹,瀏覽器還是要一個一個接。解析我們用的是 htmlparser2 這個套件,關鍵是下面這行:

import { parseDocument } from 'htmlparser2'
 
// 帶上位置,每個節點都知道自己在原文的哪一段,不用互動的部分直接從原文切出來輸出
const doc = parseDocument(html, { withStartIndices: true, withEndIndices: true })

9/3 上線之後,用 Lighthouse(Chrome 內建的效能檢測工具)對正式站同一篇文章跑,我們自己的 JS 執行時間從 3,615 毫秒降到 677 毫秒,少了 81%。改動前只跑了一次,但降幅大到不像雜訊。

實驗室少了八成,真人沒有變快

本以為這樣就修好了。9/29 回頭看 Sentry 上一週的真人資料,點標題和點內文段落都沒有變快,p75 還多了幾十毫秒(608→664、440→520)。Google 的 CrUX(Chrome 收的真人資料)裡,同一篇文章從 351 到 354,幾乎沒動。

但前後兩段時間長短不同,同一段期間廣告也換過,這幾十毫秒不能全算在這次修改頭上。

Lighthouse 測不出這件事。它量的是頁面載入的那幾秒,沒有人去點,就不會有 INP。而且我們在本機量的時候,還把廣告和第三方腳本全部擋掉,只看自己的 JS,等於把真人會遇到的情況整個拿掉了。

後來改到正式站上,用正式版 Chrome 一次一次點,找到兩個原因。

關廣告的時候,Facebook Pixel 在跑

最常被點的是廣告的關閉鈕。頁面上有 Facebook Pixel,它是 Facebook 給的一段追蹤程式,記錄訪客在網頁上做了什麼,拿來算廣告成效。網頁上的按鈕被點的時候,它也會自動記一筆,所以每按一次關閉鈕,Pixel 就多做 84 到 109 毫秒,大概佔整個 INP 的一半以上。同樣的條件各點 12 次,只差有沒有擋掉 Facebook:不擋的中位數是 200 毫秒,擋掉以後 84 毫秒。

我們放在 GTM(Google Tag Manager,讓行銷不用改程式,就能在網頁上加減追蹤程式的工具)裡的 Pixel,9/22 已經拔掉了。但另一套串接會員資料的工具也會自己載 Pixel,9/29 量的時候,頁面上還有兩個。

還沒 hydrate 完就點了

標題和段落慢,是因為點得太早。使用者點到還沒 hydrate 的地方時,React 會當場把那一塊同步 hydrate 完,才處理這次點擊。React 18 的說明是這樣寫的:

React will synchronously hydrate the comments during the capture phase of the click event.

Next.js 的 hydration 是包在 startTransition 裡跑的,會分段做,中間把主執行緒讓出來,平常不會一直卡住。但只要點到還沒 hydrate 的地方,React 就會把剩下的部分一次做完,才處理這次點擊。

「那一塊」指的是包住它的 Suspense 邊界,也就是用 <Suspense> 包起來的範圍,React 會把它當成一個單位來 hydrate。我們的文章頁沒有切,所以那一塊就是整頁。

CPU 調慢 4 倍時,hydration 兩秒內就做完,點不出這個狀況。調到 12 倍才重現,這個倍數是為了重現挑的,沒有對過真人的機型分布。

12 倍下,在進頁後 1.5 到 3.2 秒之間點標題:還沒 hydrate 完的 6 次是 536 到 680 毫秒,完成之後的 3 次是 64 到 104 毫秒,9 次沒有例外。慢的那幾次,大部分時間花在處理這次點擊,幾乎都是我們自己的 JS,也就是那次同步 hydrate。再細到是哪些元件,還沒拆。

真人的慢點擊也集中在這段時間,因為蓋版廣告大約第 3、4 秒跳出來,之後標題就被蓋住點不到了。

536 到 680,跟真人的 520 到 664 落在同一個範圍。第一輪對不起來,是因為我們把真人會遇到的東西都擋掉了。

這一條跟框架有關。上一輪我們減少的是 hydration 的總量,但整頁還是一次 hydrate 完,點得夠早的人就要一口氣等完。

會員資料工具帶進來的那個 Pixel,到 10/18 都還在頁面上。切 Suspense 要改程式,也還沒排進 Sprint 開發。

修完到複驗中間那幾週,我也去翻了幾份常被轉貼的 Next.js 效能比較,想找個東西對照。結果沒有一份對得上。

同一個版本,同一份 benchmark,兩輪差六倍

2026 年 3 月,Platformatic 的 CTO Matteo Collina 發了一份被轉很多次的比較:把 TanStack Start、React Router 和 Next.js 做成同一個電商 app,放在 Kubernetes(管理一群伺服器的平台)上,每秒灌 1,000 個請求(1,000 req/s)。第一版的結論是 Next.js 最弱,但 16 的 canary 版(還沒正式發佈的測試版)把差距拉近了。

一週後他們自己發了第二版,說第一版的壓縮設定三個框架不一致,重測:

平均回應成功率
Next.js 16.2.0-canary.66(第一版)1,440 ms約 64%
同一個版本(第二版)8,856 ms55%
TanStack Start(第二版)18 ms100%
React Router(第二版)19 ms100%

第二版沒有解釋同一個版本為什麼會差這麼多,推文裡那句「差距正在縮小」也沒有在第二版重現。

而且兩版的成功率都不到 100%。這種時候平均回應量到的多半是排隊和 timeout,1,440 和 8,856 都只是在說「撐不住」。

還有三件事要一起看:這個 benchmark 刻意不開快取,作者自己在文章裡寫了 Next.js 需要快取;三個 app 是請 Claude Code 產生的;而測試裡一直拿來對照的 Watt(一個跑 Node.js 應用程式的執行環境),是 Platformatic 自家的產品。

數字我相信是真的跑出來的。只是它測的是「不開快取、1,000 req/s 的動態 SSR」(SSR 是每次有人來,伺服器就產生一份 HTML),跟公司這種大部分頁面都能被 CDN 快取的內容站,差得有點遠。

其他幾份各缺了什麼

Northflank 2025 年 2 月說,把 Next.js 換掉之後頁面快了 50 倍。 一般頁面原本 render 要 200 到 400 毫秒,大頁面會超過 700 毫秒,換掉之後是 20 毫秒。看起來很猛,但文章沒寫用的是哪個版本、哪個 router、快取怎麼設。而且 50 倍是拿最慢的大頁面去比,700 除以 20 實際算起來是 35 倍。順帶一提,Northflank 賣的是跟 Vercel 競爭的部署平台。

顧問公司 Tasrie IT 2025 年 12 月說,官網改用 Astro 之後 Lighthouse 四項滿分。 文章裡沒有改版前的分數。而且他們是整站重寫加上換主機商,效能變好很難歸因到框架。Reddit 上那串被轉貼的「Astro vs Next.js」討論,發文的就是同一家公司。

最扎實的一份是 lazarv 2026 年 5 月做的靜態輸出測試。

10 萬頁靜態輸出build 時間輸出大小記憶體峰值
Next.js264.5 秒1.84 GiB4.33 GiB
Astro22.6 秒47 MiB沒有寫

到 20 萬頁,Next.js 直接噴 RangeError: Maximum call stack size exceeded,一頁 HTML 都沒產出。

作者有揭露自己的框架也在受測名單裡,也自己寫了:

If you have a thousand pages, all of these tools work. The differences are noise.

我的部落格快 50 篇文章。公司的網站走 SSR,不做靜態輸出,這份測試測的東西跟它沒關係。

SSR 到底幫到哪些指標

web.dev 的說法一直沒變:SSR 讓內容更早出現,代價是伺服器要先花時間產生 HTML。

generating pages on the server takes time, which can increase your page’s TTFB

Core Web Vitals 是 Google 拿來評分的三個指標:

指標門檻白話
LCP≤ 2.5 秒畫面上最大那塊內容出現的時間
INP≤ 200 毫秒點下去到畫面有反應
CLS≤ 0.1畫面跳動的程度

另外常看到的 TTFB(第一個位元組回來的時間)、FCP(第一塊內容出現的時間)、TBT(主執行緒被卡住的總時間;主執行緒是瀏覽器跑 JavaScript 和畫畫面共用的那一個),是拿來找原因用的,Google 評分只看上面三個。但 TTFB 會被算進 LCP,web.dev 給的理想配比是 TTFB 大約佔 LCP 的 40%。

SSR 加上 hydration,會多出一段看得到、但點不動的時間:

Server-side rendered pages can appear to be loaded and interactive, but can’t actually respond to input until client-side scripts execute

我們那個點太早的標題就是這種情況。

Web Almanac 2025 在 1,621 萬個網站上量到,手機端 TBT 的中位數是 1,916 毫秒,比 2024 年高了 58%。三項 Core Web Vitals 都過的網站,手機只有 48%。

JS 越來越多,hydration 就越久,點不動的那段時間也越長。

那 SSR 什麼時候真的有感?我找到控制變因最乾淨的一份,是瑞典 Blekinge 理工學院 2025 年 5 月的一份畢業專題:兩個一模一樣的 Next.js 15 app,一個 SSR、一個 CSR(在瀏覽器裡產生畫面),各跑 10 次。

SSR 的 LCPCSR 的 LCP
WiFi5.46 秒5.08 秒
節流到 3G8.03 秒27.29 秒

作者自己註明樣本數偏小,絕對值也偏高,WiFi 下 LCP 都要 5 秒多,看相對差距就好。但方向很合理:網路越差,先送一份完整 HTML 的價值越大。

HTML 進了 CDN 之後,這題會變成另一題

這是公司網站在載入速度上的結論。還好我們大部分的頁面都能被快取:Next.js 的快取全部關掉,交給 CDN 管。命中快取的時候,使用者拿到的就是 CDN 離他最近的那台機器上,一份現成的 HTML,這時候 SSR 和靜態產生沒有差別。

差別只在沒命中的那一次:SSR 要回 origin(CDN 後面真正產生頁面的那台伺服器)等它算完。所以對我們來說,載入速度看的是命中率,web.dev 給的目標是 90%。

但命中率管的只有載入。INP 是 HTML 到了之後的事,CDN 幫不上忙,上面那兩個原因都發生在這一段。

框架的「通過率」不要拿來吵架

常看到有人貼「Astro 有 X% 的網站通過 Core Web Vitals,Next.js 只有 Y%」。這個數字的來源是 HTTP Archive,而他們自己在方法論裡寫得很清楚:

an entire website’s real-user experiences are attributed to the technologies detected only on its home page

每個網站只測首頁,然後用首頁偵測到的技術代表整站。更麻煩的是,我看到兩份都說用 2026 年 4 月資料的統計,Astro 的通過率一份寫 67%、一份寫 84%,算法不一樣,差了 17 個百分點。所以這個數字我不拿來比框架。

自己量 INP 的時候

INP 要看真人的資料。我們看 Sentry,也同時看 Search Console 的 Core Web Vitals 報表,不是只看一邊。Search Console 那份是 Google 從 Chrome 收的真人資料,開頭把文章頁標成不及格的就是它,但它只分得出哪一群網址不及格,看不到點了哪裡,要拆到元素還是得靠 Sentry。

沒有 Sentry 的話,Google 的 web-vitals 套件也拿得到,attribution 版本還會幫你拆好是點到哪個元素、慢在哪一段:

import { onINP } from 'web-vitals/attribution'
 
onINP(({ value, attribution }) => {
  report({
    inp: value,
    target: attribution.interactionTarget,       // ← 點到哪個元素
    loadState: attribution.loadState,            // ← 點的時候頁面載到哪裡,點太早的會是 loading 或 dom-interactive
    inputDelay: attribution.inputDelay,          // ← 等主執行緒空下來的時間
    processing: attribution.processingDuration,  // ← 事件本身跑多久
    presentation: attribution.presentationDelay, // ← 跑完到畫面畫出來
  })
})

web-vitals v4 以上,attribution 還會帶 longAnimationFrameEntries,裡面有每段 script 的網址。Pixel 這種第三方腳本,從真人資料就看得出來,不用像我們一樣去正式站一次一次點。

收回來以後,除了照點擊次數排,還要另外拉一張照 p75 排、次數過一個門檻的表。我們第一輪只照次數排,文章頁上的字體放大按鈕每次都慢,但用的人少,一直排不進前面,是上線後一個一個點過才發現的。

看到效能數字時先問的四件事

  • 它量的是載入還是互動?Lighthouse 只看載入,沒有 INP。
  • 快取開了沒?不開快取的 SSR benchmark,跟 CDN 後面的內容站是兩回事。
  • 實驗室的條件跟真人一樣嗎?廣告、第三方腳本、使用者什麼時候點。
  • 跑了幾次、誰做的?只跑一次、作者自己賣競品,都要打折。

延伸閱讀