← 所有文章

工程 · 2026.10.01 · 4 min · 更新於 2026.10.26

Next.js 網站 Lighthouse 每次 timeout?主因是一個沒讀的 response body

首頁手機版 PageSpeed 只有 46 分,Lighthouse 每次都 timeout。主因是匿名訪客的 refresh 請求拿到 400 後沒讀 response body;修完再處理 WebGL、LCP 圖和字型,分數到 61。

閱讀設定
主題
字級
寬度

自架的 OpenSEO 用 Lighthouse 量我的 Next.js 官網首頁,每次都跑到 timeout。我第一個懷疑的是 WebGL 背景。

結果主因是匿名訪客打 refresh 拿到 400 之後,前端沒有讀 response body。Chrome 不回報這個請求結束,Lighthouse 就一直等網路閒置。

把 body 讀掉,再處理 WebGL、LCP 圖、Sentry 和字型,PageSpeed 手機版從 46 分到 61 分,TBT 從 410 毫秒降到 30 毫秒。修的過程中又發現公開頁根本不需要知道登入狀態,探測就整個拿掉了。

主因:fetch 沒讀 body

匿名訪客開公開頁時,前端會打 /api/auth/refresh 試著換 token,後端回 400。前端看到 status 就處理完了,沒有讀 body。

對使用者來說沒有差別,但 Chrome 不會回報這個請求結束。

Lighthouse 要等網路閒置才算載入完成,請求一直掛著,它就等到 45 秒的上限。

我一開始把 timeout 歸給 WebGL 背景,中間一度說過「已排除 WebGL」又翻案,繞了一圈。

最後是用 CDP(Chrome DevTools Protocol,能直接看瀏覽器網路事件的除錯介面)在線上實測確認的。看 Network 事件,找一直沒有 loadingFinished 的請求:不讀 body,就沒有這個事件;讀掉,事件就出現。

本機用 HTTP/1.1 的假後端怎麼試都重現不出來,要在有 Cloudflare 的環境才驗得到。本機重現不了的網路行為,就到真實環境用 CDP 看事件。

修法是在 performRefresh 裡把 body 讀掉。fetch 拿到回應之後,就算用不到 body 也要讀掉。

示意圖:匿名訪客的 refresh 請求回 400 後,前端沒讀 body,請求一直沒有結束,Lighthouse 一直等網路閒置,等到 45 秒上限 timeout;在 performRefresh 裡把 body 讀掉後,出現 loadingFinished 事件,網路閒置,量測正常結束。

WebGL、LCP 圖、Sentry 與字型

WebGL 背景在沒有 GPU 的機器上會用 CPU 算。改成沒有硬體加速時只畫一幀當靜態背景,有的話等 load 之後才開始動,不跟首屏搶主執行緒。

手機版的 LCP(Largest Contentful Paint,首屏最大那塊內容畫出來的時間)是一張 CSS 背景圖,瀏覽器要等 CSS 解析完才發現它;隱藏的桌機主圖和頁尾圖卻被預載。

改成 LCP 圖只在手機預載,桌機主圖只在桌機下載,頁尾圖延遲載入。

Sentry SDK 用動態 import 結果整包進來,傳輸 147 KiB。改成具名載入需要的部分,剩 27 KiB。

繁中頁的字型下載有 1.39 MB,其中 Noto Sans TC 佔 964 KB。砍字重沒用,400、500、700 共用同一批可變字型(一個檔案涵蓋多種粗細)。

全站改系統字最省,但桌機外觀會變。最後只讓寬度 1023px 以下的繁中頁內文改用系統字,桌機和英文頁不動。

手機首頁的字型下載從 1,385 KB 降到 421 KB。

其他四項修正:WebGL 背景在沒有硬體加速時只畫一幀,有的話等 load 之後才動;LCP 圖只在手機預載,桌機主圖只在桌機下載,頁尾圖延遲載入;Sentry SDK 改成具名載入,傳輸量從 147 KiB 降到 27 KiB;1023px 以下的繁中頁內文改用系統字,手機首頁字型下載從 1,385 KB 降到 421 KB。

PageSpeed 從 46 分到 61 分

上線後重跑 PageSpeed 手機版首頁。FCP 是第一塊內容畫出來的時間,TBT 是主執行緒被長任務卡住、沒辦法回應操作的總時間:

指標 修之前 上線後
效能分數 46 61
FCP 9.7 秒 5.0 秒
LCP 14.5 秒 9.0 秒
TBT 410 毫秒 30 毫秒
最佳做法 96 100

分數每次跑都會變。Lighthouse 的 FCP 模擬值會在 2 秒和 9.7 秒之間跳;字型如果在首次繪製前下載完,會被算成擋繪製的資源。單看一次的分數容易誤判。

Google 目前沒有 ezlo.me 的實際使用者資料,這個分數現在不影響排名。這次的收穫主要是量測工具終於量得出數字,以及手機上的載入時間。

效能問題常常是好幾件事疊在一起。先把量測修到量得出數字,再一項一項拆。

公開頁不再探 /me

修 refresh 時,另一個問題也浮出來。公開頁每次掛載都會探一次 /me,決定右上角顯示「登入」還是頭像。

JWT cookie 設了 HttpOnly(JavaScript 讀不到),而且掛在 API 網域,前端只能打了才知道。

匿名訪客佔絕大多數,等於每頁多 4 個請求,換來右上角一個圖示。

方向前後換了三次。先是加一個 localStorage 的「登入過」記號,接著發現站上沒有換頭像的功能,最後才想到首頁根本不需要知道登入狀態。

最後的設計是公開頁右上角固定顯示「登入」,按下去由登入頁探 /me,已登入就直接導去儀表板。

登入頁吃一個 next 參數決定登入後去哪,但只收 /dashboard 底下的站內路徑。外站網址、//host 這種省略協定的寫法、帶 .. 的路徑,一律回儀表板首頁,避免變成能把人導去外站的連結(open redirect)。

我也想過讓後端加一個永遠回 200 的 session 端點,但那樣還是多一個請求,也要動後端。

改完之後,匿名訪客開首頁只剩三個公開 API 請求,console 沒有錯誤。

回頭看第一版,記號加頭像等於替一個不需要的功能加機制。加機制之前,先確認那個功能需不需要存在。

#Lighthouse#Web 效能#前端#Next.js

留言

載入留言中…

下一篇

工程

新網域怎麼做 SEO:用自架 OpenSEO 查搜尋量,再照資料寫長尾教學文章

品牌詞被同名品牌佔走,新網域只能靠教學內容接長尾搜尋。用自架 OpenSEO 查搜尋量重排題目,每篇對一手來源查證,GA4 只裝在公開頁。