工程 · 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 也要讀掉。

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。

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 沒有錯誤。
回頭看第一版,記號加頭像等於替一個不需要的功能加機制。加機制之前,先確認那個功能需不需要存在。

工程
新網域怎麼做 SEO:用自架 OpenSEO 查搜尋量,再照資料寫長尾教學文章
品牌詞被同名品牌佔走,新網域只能靠教學內容接長尾搜尋。用自架 OpenSEO 查搜尋量重排題目,每篇對一手來源查證,GA4 只裝在公開頁。
留言
載入留言中…