avatar
Published on

Express 要不要加 Compression?從 localhost Lighthouse 到 Cloudflare 架構評估

Authors
  • avatar
    Name
    Harry
    Twitter

Express 要不要加 Compression?從 localhost Lighthouse 到 Cloudflare 架構評估

完成首頁 CLS 與 WebP/LCP 優化後,我在本機 production build 嘗試替 Express 加上 HTTP response compression。Lighthouse 的總傳輸量從 3,077 KiB 降到 1,572 KiB,Performance 從 75 上升到 86,看起來是很直接的改善。

但進一步檢查正式環境後,結論改變了:使用者收到的 HTML、CSS 與 JavaScript 原本就已由 Cloudflare 以 Brotli 傳輸。把 compression middleware 加進 Express,主要影響的是 origin 到 Cloudflare 的傳輸與未命中快取的 request,卻會增加 Node.js 即時壓縮的 CPU 成本。

因此這次的最後決定是:保留實驗與驗證紀錄,但從 PR 移除 Express compression。 在缺少 origin response time、Cloudflare cache hit ratio 與 pod CPU 等資料前,不能只根據 localhost Lighthouse 分數決定 production 架構。

這種優化叫什麼?

最精確的名稱是 HTTP response compression(HTTP 回應壓縮),也可以稱為 Brotli/Gzip 傳輸壓縮或 network payload optimization。

它和以下幾種優化不同:

  • Minification:移除不必要的空白與註解,並將可安全改名的識別字縮短(mangle)。
  • Tree shaking:從 bundle 移除未使用的 export。
  • 圖片壓縮:改變 PNG、JPEG、WebP 或 AVIF 的編碼方式。
  • HTTP response compression:不改變解壓後內容,只減少網路上傳送的 bytes。

例如瀏覽器最後仍會執行同一份 JavaScript,但網路上實際傳輸的資料可以小很多。這能改善下載時間,卻不會降低 JavaScript parse、compile、evaluation 或 React hydration 的成本。

本機實驗怎麼做?

原本的 Express server 直接提供靜態檔案:

const server = Express()

server.disable('x-powered-by').use(Express.static(process.env.RAZZLE_PUBLIC_DIR, { maxAge: '14d' }))

實驗版本安裝 compression,並把 middleware 放在 Express.static 和 SSR route 前面:

import compression from 'compression'
import Express from 'express'

const server = Express()

server
  .disable('x-powered-by')
  .use(compression())
  .use(Express.static(process.env.RAZZLE_PUBLIC_DIR, { maxAge: '14d' }))

瀏覽器會在 request 帶上支援的編碼:

Accept-Encoding: gzip, deflate, br

Server 選擇 Brotli 時,response 會包含:

Content-Encoding: br
Vary: Accept-Encoding

Vary: Accept-Encoding 告訴 cache:同一個 URL 可能有不同編碼版本,不能把 Brotli response 交給不支援 Brotli 的 client。

如何正確驗證?

用 Chrome DevTools Network

  1. 開啟 Network 面板並勾選 Disable cache。
  2. 重新整理頁面。
  3. 分別選擇首頁 Document、CSS bundle 和 JavaScript bundle。
  4. 在 Headers → Response Headers 檢查 Content-Encoding 和 Vary。

本機實驗中的 CSS response 顯示:

Content-Encoding: br
Chrome DevTools Network 顯示本機 CSS bundle 使用 Brotli Content-Encoding

這張圖只證明該 CSS response 使用 Brotli,不能單獨證明所有資源都有壓縮,也不能證明 production 必須由 Express 壓縮。

用 curl 驗證

Production build 的檔名包含 content hash,不能直接假設路徑是 /static/css/bundle.css。先從 build manifest 取得實際路徑:

CSS_PATH=$(node -p "require('./build/assets.json').client.css")

curl -sS -D - -o /dev/null \
  -H 'Accept-Encoding: br' \
  "http://localhost:3000${CSS_PATH}"

預期同時看到正確的 status、content type 與 encoding:

HTTP/1.1 200 OK
Content-Type: text/css; charset=UTF-8
Vary: Accept-Encoding
Content-Encoding: br

如果路徑不存在,Express.static 可能把 request 交給 SSR fallback。這時即使看到 Content-Encoding: br,只要 Content-Type 是 text/html,驗證到的仍是 HTML,不是 CSS。

再用 GET request 測試 Gzip:

curl -sS -D - -o /dev/null \
  -H 'Accept-Encoding: gzip' \
  http://localhost:3000/

不要改用 curl -I。-I 送出的是 HEAD request,而 compression@1.8.2 會跳過 HEAD;沒有 Content-Encoding 不代表壓縮設定失敗。-D - -o /dev/null 仍送出 GET,只是不把 body 顯示在 terminal。

localhost Lighthouse 觀察到了什麼?

兩次測試都使用本機 production build 與 Lighthouse Mobile simulated throttling:

指標WebP/preload 後Express compression 實驗變化
Performance7586+11
First Contentful Paint3.9 s1.9 s-2.0 s
Largest Contentful Paint3.9 s1.9 s-2.0 s
Speed Index5.7 s3.2 s-2.5 s
Total Blocking Time0 ms450 ms+450 ms
Cumulative Layout Shift00不變
Total payload3,077 KiB1,572 KiB約 -49%

主要文字資源的 transfer size 也明顯下降:

資源壓縮前壓縮後約減少
HTML39,989 bytes9,880 bytes75%
CSS bundle201,380 bytes32,771 bytes84%
JavaScript bundle1,275,144 bytes344,378 bytes73%
圖表 chunk518,083 bytes130,053 bytes75%

這證明 middleware 在本機確實減少了 Express 到瀏覽器的傳輸量。然而 localhost 的連線拓撲是:

browser → Express

正式環境則是:

browser → Cloudflare → Express origin

兩者不是同一個測試條件,因此本機減少 49% 不能直接當成正式環境使用者也會獲得同等改善。

為什麼 TBT 從 0 變成 450 ms?

這不是 Brotli 解壓縮讓主執行緒多做了 450 ms。TBT 只計算 FCP 到 TTI 之間,每個 main-thread task 超過 50 ms 的部分:

blocking time = max(task duration - 50 ms, 0)

壓縮前,主 bundle 與圖表 chunk 下載較慢,相關 long tasks 落在 Lighthouse 的 TBT 計算區間之外。壓縮後,JavaScript 更早下載完成並開始執行,原本被慢速下載藏在後面的 CPU 成本因此進入計算區間。

本次報告中總 main-thread work 反而從 2.2 s 降到 1.7 s,所以不能把 TBT 上升直接解讀成 Brotli 解壓縮造成的 regression。不過單次 Lighthouse 數字仍會受 CPU 排程、背景程式和第三方 script 影響,應重複測試並取中位數。

為什麼最後移除 Express compression?

正式站的 DevTools 與 response header 已顯示,使用者收到的 HTML、CSS 與 JavaScript 原本就有:

Content-Encoding: br

也就是 Cloudflare 已負責 browser-facing compression。此時再把 middleware 放進 Express,可能只在 Cloudflare 回源或 cache miss 時減少 origin 傳輸量,對一般使用者不一定帶來本機 Lighthouse 所顯示的改善。

PR review 的外部測量指出,SSR HTML 未壓縮約 31–50 KB、Brotli 後約 7–12 KB,每次未快取頁面約節省 25–40 KB 的 origin 傳輸。這個節省是否值得 Node.js 的即時壓縮 CPU 成本,必須配合實際流量與監控資料評估,不能只看檔案縮小比例。

Express 官方文件也建議在 production 由 reverse proxy 層處理 compression。對 CSS、JavaScript 這類不隨 request 改變的資源,還可以在 build 或 CDN 階段預先壓縮,避免每次 request 都消耗應用程式 CPU。

因此要重新評估應用層壓縮,至少需要:

  • Cloudflare cache hit ratio 與回源 request 數量
  • origin response time/TTFB
  • Node.js pod CPU 使用率與尖峰負載
  • 壓縮前後的 origin egress 差異
  • CDN 是否已能完成 Brotli/Gzip negotiation 與 cache

在這些資料齊全前,移除 middleware 是較符合目前架構的選擇。

Brotli quality 4 與 11 的取捨

compression@1.8.2 的即時 Brotli 預設 quality 是 4,不是最高的 11。這是 response time、CPU 成本與檔案大小之間的折衷;即時壓縮直接使用 quality 11,可能以較高的 server CPU 與延遲換取更小的 response。

預先壓縮的靜態檔案不佔用每次 request 的 CPU budget,因此可以考慮 quality 11,再由 CDN 根據 Accept-Encoding 回傳相對應的版本。動態 SSR HTML 是否需要在 origin 即時壓縮,則應由上述 production 指標決定。

結論

  • HTTP response compression 確實能減少 HTML、CSS 與 JavaScript 的傳輸 bytes。
  • 本機實驗的 payload 從 3,077 KiB 降到 1,572 KiB,只能證明 middleware 生效,不能直接代表 production 使用者收益。
  • Production 已由 Cloudflare 對瀏覽器提供 Brotli;Express compression 的主要潛在收益改成 origin 到 Cloudflare 的傳輸。
  • 即時壓縮有 Node.js CPU 成本,必須與 cache、origin latency、egress 和 pod CPU 一起衡量。
  • 因此這次從 PR 移除 Express compression,保留已驗證的 CLS 與 WebP/LCP 優化。