我們公司的健康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 ms | 55% |
| TanStack Start(第二版) | 18 ms | 100% |
| React Router(第二版) | 19 ms | 100% |
第二版沒有解釋同一個版本為什麼會差這麼多,推文裡那句「差距正在縮小」也沒有在第二版重現。
而且兩版的成功率都不到 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.js | 264.5 秒 | 1.84 GiB | 4.33 GiB |
| Astro | 22.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 的 LCP | CSR 的 LCP | |
|---|---|---|
| WiFi | 5.46 秒 | 5.08 秒 |
| 節流到 3G | 8.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 後面的內容站是兩回事。
- 實驗室的條件跟真人一樣嗎?廣告、第三方腳本、使用者什麼時候點。
- 跑了幾次、誰做的?只跑一次、作者自己賣競品,都要打折。
延伸閱讀
- 我用不到 Next.js 的大半功能,它還是決定了我的架構:這篇刻意沒談的選型
- 刷了 CDN 還是舊資料:Next.js 的快取沒有清除按鈕:為什麼最後把快取全部交給 CDN
- 三成的 500 卡在剛好 10 秒:undici 預設的 connect timeout:另一個「慢」但跟框架無關的例子