~/blog/vercel-edge-requests-must-revalidate-trap

Vercel Edge Requests 1M/1M 爆了,原因是一行 cache header

cat --toc

TL;DR

ai-muninn 這個月 Vercel Edge Requests 用到 1M/1M,免費資源被擋。不是流量暴漲,不是 bot 攻擊,也不是圖太大。是 Next.js 預設讓 /public/*must-revalidate,瀏覽器每次都要送 conditional GET,連 304 都算一次 edge request。修法是 next.config.ts 加 immutable cache,3 行 config 搞定。但 Vercel 配額是 30 天滾動,這個月撞上的牆要等到 5/22 之後才會完全乾淨。

白話版:cache HIT 不等於沒花錢

你以為瀏覽器有 cache 就不會打到伺服器?對一半。

如果 response 裡帶一個叫 must-revalidate 的 header,加上 max-age=0(瀏覽器一拿到就視為 stale),每次載入都得回頭跟伺服器確認一句「我的版本還是最新的嗎?」即使伺服器回「是的,沒變」(HTTP 304,沒重傳檔案),這個一來一回還是算一次請求。

Vercel Hobby 免費版給 100 萬次請求。重點是:不是日曆月,是 30 天滾動視窗。後面 Part 6 會講為什麼這個差別會害你慘。

一篇文章載入 = 1 個 HTML + 1 個 OG 圖 + 1 個封面影片 + N 個內文圖。讀者 reload 一下 = 全部重來。然後我有快 30 篇文章。算一算就懂為什麼會爆。

修法很簡單,3 行 config 改完就好。但 Vercel 是 30 天滾動配額,這個月已經撞上的牆,要等 5/22 才會完全乾淨。


場景:4/27 中午看到紅燈

那天中午我打開 Vercel dashboard 準備發新的 blog。

Vercel Usage panel: Edge Requests 1M/1M 爆紅,其他資源都還健康

Exceeded free resources ⚠️

Edge Requests 用了 1,002,906 / 1,000,000。Fluid CPU 2h44m / 4h、ISR Reads 292K / 1M、Function Invocations 240K / 1M,其他全綠。問題就 isolate 在 edge requests 一條線。

但坐下來想:這數字代表有人在看。ai-muninn 是個寫繁中技術內容的小 blog,能把 Vercel 免費額度頂到牆,技術上是 footgun,社群上其實是好消息。一邊修一邊承認,可喜可賀。

接下來的問題是:到底是「真的有 100 萬有效讀者」,還是「config 沒設好放大 10 倍」?兩個都要查清楚。對自己誠實,對 cost 也要誠實。


Part 1:三條死路

死路一:「是不是流量暴漲?」

打開 GA4 看 28 天 sessions:1,733。比上個月微跌。沒有。

死路二:「是不是 bot 攻擊?」

想撈 Vercel runtime log 看 User-Agent 比對。Hobby plan 的 runtime log 只保留 1 小時、上限 4,000 rows(Vercel Runtime Logs docs)。我打開 dashboard 只看到 200 多條 log 覆蓋大概 1 分鐘。spike 已經是好幾天前的事,根本撈不到。

升 Pro 才能拿到 30 天的 runtime log retention(透過 Pro 含的 Observability Plus)。但那是「事後可查」,事發當下還是抓不到。

死。

死路三:「是不是圖太大?」

我沒換大圖。最近上的 Express 系列圖都是 16:9 的 PNG,~200KB。

而且問題是「請求次數」不是「bandwidth」。圖再大也只算一次。

三條死路走完,卡 30 分鐘。


Part 2:真因是 must-revalidate 跟 cache HIT 同時成立

死馬當活馬醫,curl -I 看任何一個 OG image:

$ curl -I https://ai-muninn.com/og/some-post.png

cache-control: public, max-age=0, must-revalidate
x-vercel-cache: HIT

兩行同時出現,看了三秒才反應過來。

x-vercel-cache: HIT 表示 Vercel CDN 有 cache,沒回 origin。✓

max-age=0, must-revalidate 則是強迫瀏覽器每次載入都回頭跟 server 確認一次。即使 server 回 304 Not Modified、沒重傳檔案內容,這個 round-trip 仍然算一次 edge request。

Vercel 算 quota 算的是 request 次數,不是 bandwidth,也不是 cache miss。CDN HIT、回 304、用戶只看到 cache 圖,但那一次 conditional GET,算錢。

數學

每篇 blog 文章載入 = 1 個 HTML + 1 個 OG image + 1 個封面影片 + N 個內文圖。一個讀者讀 5 篇 = 至少 25 次 edge request,就算瀏覽器有 cache 也一樣。因為 max-age=0 讓瀏覽器一拿到就把 cache 當 stale,必須回 server 重新驗證。

ai-muninn 27 篇文章 × 不固定的內文圖 + sitewide 共用 OG/favicon/影片,算下來日均 5-10 萬請求很合理。

為什麼會這樣設計

Next.js 的 /public/* 預設Cache-Control: public, max-age=0。Vercel 平台層再加上 must-revalidate參考 Vercel Cache-Control headers docs),確保新 deploy 後瀏覽器立刻拿到新版。

對 build artifact 合理,你可能 build 後直接覆蓋同名檔案。對 blog 場景剛好不適用,我的圖、影片、OG 檔名都跟 slug 綁,永遠不會用同名重傳。


Part 3:30 天時間軸,一張圖看完

Vercel Edge Requests 30 天 chart:4/9 ~110K spike 後多日高峰,4/27 之後 daily drop 到 5-15K

這張比所有文字都直接:

區間區間內請求量備註
4/1 ~ 4/6< 10K/day月初平緩
4/9 單日~110K月內最高單日 spike
4/9-10 兩天合計~200K吃掉月配額 20%
4/13-15 三天合計~200K另一波
4/21-22 兩天合計~135K最後一波
4/27fix 部署
4/28-30 三天5-15K/daydaily 砍掉 90%

Apr 9-22 那幾組 spike 應該是被分享出去(threads、reddit、HN?),Hobby 的 1 小時 log 看不到 referer,事後查不出來。這是另一個教訓:免費版可以學習,不適合 incident debug。


Part 4:修法 3 行 config

next.config.ts

import type { NextConfig } from 'next'

const IMMUTABLE_CACHE = 'public, max-age=31536000, immutable'

const nextConfig: NextConfig = {
  async headers() {
    return [
      { source: '/videos/:path*', headers: [{ key: 'Cache-Control', value: IMMUTABLE_CACHE }] },
      { source: '/og/:path*',     headers: [{ key: 'Cache-Control', value: IMMUTABLE_CACHE }] },
      { source: '/images/:path*', headers: [{ key: 'Cache-Control', value: IMMUTABLE_CACHE }] },
    ]
  },
}

驗證:

$ curl -I https://ai-muninn.com/og/some-post.png

cache-control: public, max-age=31536000, immutable
x-vercel-cache: HIT

immutable 在 freshness lifetime 內,瀏覽器正常情況不會回頭驗證。force reload / hard refresh 還是會送 conditional GET,這是 RFC 8246 規定的使用者覆寫權。99% 的場景:conditional GET 全部省掉。


Part 5:Footgun,immutable 真的 immutable

immutable 解決了配額問題,也帶來新風險:如果你之後用同一個檔名上傳新版本,使用者會卡舊版直到 cache 過期或 hard refresh。

規避:

  • 檔名跟 slug 綁,不要重覆使用(我的 OG 是 <slug>.png
  • 若必須換檔,加 ?v=2 query string 強制 cache bust(MDX 改一下圖片 src 即可)
  • 真的緊急要改,可以暫時把那條 source rule 拿掉、deploy、等 cache 滾完再加回來

對 blog 場景,這個 trade-off 划算。我寧願改檔名,也不要每個讀者每次 reload 都打 edge。


Part 6:30 天滾動的代價,跟一個沒看的 inbox

修對了,但配額拿不回來。

「啊明天就 5/1,配額會重置吧?」

不會。我也以為會,後來查證:Vercel Hobby 用的是 30 天滾動視窗,不是日曆月。每天系統都看過去 30 天的累計,不會因為跨月份而清零。

所以今天(4/30)我用了 1M,要等 4/9 那 110K spike day「滾出」30 天視窗之後,配額才會慢慢恢復。也就是 5/9 之後才會明顯緩、5/22 之後才會完全乾淨。

這段時間 ai-muninn 還在跑,但 Vercel 會節流,部分功能受限、部分 request 直接擋掉。「跨月重置」是個直覺陷阱,第一個直覺反應對 90% 的人都是錯的。

Vercel 其實有準時預警,是我沒看

升完 Pro 之後我才打開 Vercel inbox:

  • Apr 21:75% email 寄到了,那時離爆掉還有 9 天緩衝
  • Apr 30 上午:100% email 寄到了,這次反而是事後通知
  • inbox 未讀數:60,包含這兩條 + 一堆 deploy failed + data sharing opt-out 通知

75% 預警準時到,距離爆掉還有 9 天。我只要當天 reply 一下、看一眼 dashboard,就會發現第三波 spike (Apr 21-22) 是壓垮駱駝的最後一根稻草,可以提前修 cache header。

但我沒看 inbox。

Vercel Hobby 內建 75% / 100% 用量 email + dashboard 通知,這套系統本身沒問題。問題是我把 Vercel notifications 當 /dev/null。inbox 累積 60 條未讀,重要警告跟「team opted out to data-sharing」這種雜訊混在一起。

通知系統再好,送到沒人看的 inbox 等於沒送。下一輪要做的:

  • 把 Vercel 通知 forward 到 Telegram(我每天會看的地方),用 webhook integration 或 email forward + filter
  • 把 inbox 的 60 條清乾淨,建立每週 review 習慣
  • deploy failed 通知也要看(inbox 裡 ai-muninn 4/15 連續失敗三次部署我都不知道)
  • 任何用 must-revalidate 預設的服務,部署後 curl 一下確認 header 是想要的

Pro 還可以設自訂 threshold(例如 50%)+ spend cap,但那是 nice-to-have。先把 75% 通知送到我會看的地方,比設更敏感的閾值重要。


Part 7:這篇文章本身會有什麼問題

幾個誠實的 risk,免得之後被讀者打臉:

Streisand effect。 這篇如果被分享出去(HN / Reddit / threads),下個 30 天週期可能再撞牆一次。為了寫一篇「edge requests 怎麼省」的文章而重新燒掉 edge requests。你正在讀這段,謝謝,順便算一下你這次 page view 貢獻了幾個 request(OG 1 + cover 影片 1 + 5-8 個內文圖)。

5/30 數據可能不符合預期。 我預測 monthly 從 1M 降到 200-400K,但這篇文章本身可能 trigger 新一波 spike 蓋過 fix 效果。如果預測錯,5/30 會發 follow-up 認錯。

immutable 是會傷人的工具。 讀者照抄那 3 行很爽,但檔名穩定性不確定的場景會卡使用者於舊版。Part 5 的 footgun 一定要看完,不確定就先用 max-age=86400


Part 8:寫到一半我改主意了,升了 Pro

原本打算的結論是「不升 Pro,加 webhook 就好。daily 10K,monthly ~300K,well within Hobby 配額」。很 frugal、很符合「3 行 config 救你」的 hook。

但寫完跑 Codex + Gemini fact-check(/debate skill),加上真的按了 Upgrade 之後,三件事一直被打臉:

步驟我以為的故事真實
第一稿Vercel Hobby 沒預警Hobby 有 75/100% email,是我沒看 inbox
Codex fact-checkPro 也沒 30 天 logPro 內 enable Observability Plus 就有,1M events/mo 內不另收費(changelog
升完看 inbox預警太晚75% 預警 9 天前就到了

升完實際拿到:

項目Hobby (free)Pro ($20/mo)
Edge Requests1M / 30d rolling10M
Runtime log retention1h / 4000 rows30 days(OP 含 1M events/mo 不另計)
Spend cap / 自訂 alert
解封現在被擋的功能等 5/9 ~ 5/22立刻
文章紅了不會再爆風險10x buffer

對寫文寫到撞牆的人,$20/mo 換不必焦慮、即時解封、事後可 debug log,合理。對純愛好或流量更小的 blog,可能 Hobby + 把 Vercel inbox 通知 forward 到 Telegram 更划算。沒有絕對答案。


結尾:5/30 follow-up

修法部署在 4/27(commit 0d9717c),driven by 30 天滾動配額,5/30 才能拿到第一個完整月的乾淨數據。

三個觀察點:

  1. fix 有效嗎?預期 daily 5-15K,monthly ~200-400K
  2. Pro 升級值得嗎?$20/mo × 1 個月,看 5/30 用量再決定要不要保留
  3. 這篇文章本身會不會再 trigger 一次爆量?Streisand effect 風險(Part 7 第 1 點)

如果 5/30 看數據還是接近 1M,我抓錯真因,會發 follow-up 認錯。

如果有效,這 3 行 config + $20 升級可能是 ai-muninn 史上最 cost-effective 的兩個 commit。


Refs

常見問題

Vercel Hobby plan 的 Edge Requests 1M 限制怎麼算?
30 天滾動視窗,每個打到 Vercel Edge Network 的 HTTP request 都算一次,包含 cache HIT、304 Not Modified、conditional GET。重點:cache HIT 不代表沒打到 edge。Hobby 超過 1M 之後免費資源會被擋住,要升 Pro 或等 30 天滾出去。
為什麼 cache HIT 還會吃 Edge Request?
如果 response 帶 `cache-control: public, max-age=0, must-revalidate`,瀏覽器每次載入都會送 conditional GET 確認檔案有沒有變。即使伺服器回 304 Not Modified、沒回傳檔案內容,這次 round-trip 仍然是一次 edge request。Vercel 算 quota 是算 request 次數,不是算 bandwidth。
Next.js public/ 目錄的預設 cache header 是什麼?
Next.js 把 `/public/*` 視為「檔名不變但內容可能更新」的資產,預設回 `cache-control: public, max-age=0, must-revalidate`。這個保守值對 build artifact 是合理的,但對 blog 場景的圖片、影片、OG image(檔名 slug-stable,永不更動)會大量浪費 edge request。
怎麼修 Next.js public/ 的 cache header?
在 `next.config.ts` 的 `headers()` 加 source 規則,明確指定要長 cache 的路徑回 `public, max-age=31536000, immutable`。一年內瀏覽器正常情況不會回頭驗證。注意:immutable 真的 immutable,要 cache bust 必須改檔名或加 `?v=N` query string。
怎麼預防再發生?應該設多少門檻的預警?
Vercel Hobby 內建 75% / 100% email + dashboard 通知([Vercel Notifications docs](https://vercel.com/docs/observability/usage)),但通知到的是 Vercel inbox。如果你跟我一樣 inbox 累積 60 條未讀,警告會被淹沒。比起設更敏感的閾值,先把 Vercel 通知 forward 到你每天會看的地方(Telegram / Slack)。Pro 升級可以設自訂 spend cap + threshold。