跳到內容

執行模型:`fetch` handler、`ctx` 與請求生命週期

查證日期
驗證環境wrangler@4.114.0·workerd@1.20260722.1·compatibility_date: 2026-07-24

一次 invocation 從進來到被回收,中間發生了什麼?知道這件事會直接決定三種常見 bug 會不會出現在你的 production:

  1. 背景工作神秘地沒跑完。 你在 handler 裡發了一個不 await 的 fetch() 去打分析服務,本機測起來好好的,上線後資料時有時無。
  2. 地理判斷在本機「正常」,上線後行為不同。 因為 wrangler dev 給你的 request.cf 是一份長得很像真的假資料
  3. 一個下游服務掛掉,整個站跟著 500。 因為你不知道有 passThroughOnException()

這篇會把這三件事各用一個實驗打死。


2026 年的 Worker 一律是 ES module 語法:

export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
return new Response("hello");
},
} satisfies ExportedHandler<Env>;

三個參數的角色要分清楚:

參數是什麼生命週期
request這次的 HTTP 請求這次 invocation
env所有 binding(第 4 篇詳談)整個 Worker 版本
ctx這次 invocation 的執行控制權這次 invocation

satisfies ExportedHandler<Env> 這個寫法值得養成習慣:它讓 TypeScript 檢查你的 handler 簽章正確,同時保留具體的物件型別(不像 : ExportedHandler<Env> 會把型別擦掉)。

官方 handlers 文件頁列了六個 handler。但 wrangler types 產生的型別定義列了九個

interface ExportedHandler<Env, QueueHandlerMessage, CfHostMetadata, Props> {
fetch?: ExportedHandlerFetchHandler<...>;
connect?: ExportedHandlerConnectHandler<...>;
tail?: ExportedHandlerTailHandler<...>;
trace?: ExportedHandlerTraceHandler<...>;
tailStream?: ExportedHandlerTailStreamHandler<...>;
scheduled?: ExportedHandlerScheduledHandler<...>;
test?: ExportedHandlerTestHandler<...>;
email?: EmailExportedHandler<...>;
queue?: ExportedHandlerQueueHandler<...>;
}

💡 這是一個會反覆出現的模式:wrangler types 產生的型別檔比文件頁更新、更完整。 想確認某個 API 到底存不存在、簽章長什麼樣,翻 worker-configuration.d.ts 比 Google 快也準。

同一支 Worker 可以同時匯出多個 handler —— 一支 Worker 既服務 HTTP、又消費 Queue、又跑 cron,是很常見的做法。

ctx:文件寫了四個,實際上有七個

Section titled “ctx:文件寫了四個,實際上有七個”

官方的 Context 文件頁介紹了 waitUntilpassThroughOnExceptionpropsexports 四個成員。實際 dump 出來:

Terminal window
$ curl -s localhost:8787/ctx
{
"own": ["tracing", "access", "cache", "props", "exports"],
"prototype": ["waitUntil", "passThroughOnException", "constructor"]
}

對照 worker-configuration.d.ts 裡的定義:

interface ExecutionContext<Props = unknown> {
waitUntil(promise: Promise<any>): void;
passThroughOnException(): void;
readonly exports: Cloudflare.Exports;
readonly props: Props;
cache?: CacheContext;
readonly access?: CloudflareAccessContext;
tracing: Tracing;
}

多出來的三個 —— tracing(第 39 篇的自訂 span)、access(Cloudflare Access 身分)、cache(快取控制)—— 在那一頁完全沒提。

還有一件事:ExecutionContext<Props>泛型的。props 的型別由呼叫端決定,這是 service binding 傳遞可信設定的機制(第 18 篇)。

⚠️ 一個 2025 年的行為改變:ctx 物件不再跨 invocation 重用。 這個修正在 2025-05-27 生效並回溯套用。任何「把東西掛在 ctx 上留給下次請求用」的舊寫法都已失效 —— 那本來也是 bug。

ctx.waitUntil():背景工作的唯一合法途徑

Section titled “ctx.waitUntil():背景工作的唯一合法途徑”

這是本篇最重要的一節。

回應一送出,Cloudflare 就可以回收這次 invocation 的執行環境。任何還沒完成的 promise 會被直接砍掉。 沒有錯誤、沒有警告,就是沒跑完。

ctx.waitUntil(promise) 的意思是「這個 promise 完成之前,先別回收我」。

實驗:fire-and-forget 到底會不會跑完

Section titled “實驗:fire-and-forget 到底會不會跑完”
const events: string[] = [];
case "/fire": {
const mode = url.searchParams.get("mode") ?? "none";
const task = (async () => {
await scheduler.wait(2000);
events.push(`${mode}:completed`);
})();
if (mode === "waituntil") ctx.waitUntil(task); // the only difference
events.push(`${mode}:started`);
return Response.json({ mode, started: true });
}

實測(等 4 秒才查,遠超過那 2 秒的延遲):

Terminal window
$ curl "localhost:8787/fire?mode=none"
{"mode":"none","started":true}
$ sleep 4; curl localhost:8787/events
{"events":["none:started"]} # ← none:completed 從未出現
$ curl "localhost:8787/fire?mode=waituntil"
{"mode":"waituntil","started":true}
$ sleep 4; curl localhost:8787/events
{"events":["none:started","waituntil:started","waituntil:completed"]}

none:completed 永遠不會出現。 那個 promise 在回應送出的瞬間就被丟棄了。

這個實驗在本機就能重現(和第 1 篇的時鐘凍結不同,那個只在 production 生效),所以你可以自己跑一次確認。

  • 上限 30 秒:官方原文是 “can extend execution for up to 30 seconds after the response is sent or the client disconnects”。注意後半句 —— 使用者關掉分頁不會中斷你的背景工作。
  • 逾時會被取消並留下 logwaitUntil() tasks did not complete within the allowed time after invocation end and have been cancelled.
  • 可以呼叫多次,其中一個 reject 不會影響其他的。
  • 它不是 wall time 的上限。HTTP 請求本身只要客戶端還連著就沒有時間限制;30 秒限制的是「回應之後」那一段。

適合:寫 analytics、送 log、更新快取、丟訊息進 Queue、非關鍵的通知。

不適合:任何使用者需要知道結果的事。如果失敗了要重試、要告警、要保證送達,那不是 waitUntil 的工作 —— 那是 Queues(第 19 篇)或 Workflows(第 21 篇)。waitUntil 沒有重試、沒有持久化,isolate 掛了就沒了。

判準很簡單:waitUntil 是 best-effort。你能接受偶爾掉一筆嗎? 能就用,不能就用 Queue。

預設情況下,handler 丟出未捕捉的例外 → 使用者拿到 error page。

呼叫 ctx.passThroughOnException() 之後,同樣的例外會改成把請求原封不動送去 origin。對「Worker 是加在既有網站前面的一層」這種架構(middleware、A/B test、header 改寫),這是必備的保險:你的 Worker 出 bug,網站還是活的。

⚠️ 有一個很容易忽略的限制,官方原文:

“The Workers Runtime uses streaming for request and response bodies. It does not buffer the body. Hence, if an exception occurs after the body has been consumed, passThroughOnException() cannot send the body again.”

也就是說 —— 如果你已經讀過 request body(await request.json()),passthrough 就救不了你了,因為 body 是串流,讀掉就沒了。要保留 fail-open 能力,就得在讀 body 之前先 request.clone()

實測補充:在 workers.dev 或本機 wrangler dev 上,後面根本沒有 origin,所以兩種情況都是 500:

Terminal window
$ curl -o /dev/null -w "%{http_code}\n" "localhost:8787/boom"
500
$ curl -o /dev/null -w "%{http_code}\n" "localhost:8787/boom?pass=1"
500

差別要在「Worker 掛在有 origin 的自訂網域路由上」才看得出來。這也是為什麼這個功能容易被誤解成沒作用。

request.cf:本機的假資料長得很像真的

Section titled “request.cf:本機的假資料長得很像真的”

request.cf 帶著 Cloudflare 邊緣算出來的中繼資料,所有方案都能用:

request.cf?.country // "TW"
request.cf?.colo // "TPE" — 三碼 IATA 機場代碼
request.cf?.city // "Taipei"
request.cf?.asn // 3462
request.cf?.tlsVersion // "TLSv1.3"
request.cf?.timezone // "Asia/Taipei"

還有 continentlatitudelongitudepostalCoderegionhttpProtocoltlsClientAuth(mTLS 用),以及只有買了 Bot Management 才有的 botManagement

🔴 這是本篇第二個陷阱。wrangler dev 裡實測:

Terminal window
$ curl -s localhost:8787/cf
{"cf":{"asn":395747,"colo":"DFW","city":"Austin","region":"Texas","regionCode":"TX",
"postalCode":"78701","country":"US","continent":"NA","timezone":"America/Chicago",
"latitude":"30.27130","longitude":"-97.74260","httpProtocol":"HTTP/1.1",
"tlsVersion":"TLSv1.3","tlsCipher":"AEAD-AES128-GCM-SHA256", ...}}

我人不在德州奧斯汀。這是 wrangler 的預設 placeholder,而 dev log 裡只有一行不起眼的警告:

[wrangler:warn] Unable to fetch the `Request.cf` object! Falling back to a default placeholder...

問題在於這份假資料完全合法:欄位齊全、經緯度合理、時區對得上城市。任何 geo routing、地區限定、時區換算的邏輯在本機都會「正常運作」,而且是用假資料正常運作。

wrangler dev 在網路正常時會嘗試向 Cloudflare 抓一份真的 cf 物件;抓不到才 fallback。所以你看到的可能是真的、也可能是假的 —— 這種不確定性本身就是問題。)

規則:任何依賴 request.cf 的邏輯,一律要在部署後驗證。 而且程式碼要防 undefined —— dashboard 的 Playground 預覽環境裡它根本不存在。

一次 invocation 能發出的 subrequest(fetch()、binding 呼叫)有上限:

FreePaid
Subrequest / 次呼叫5010,000(可提高至 10M)
同時等待 response header 的連線66

那個 6 條同時連線的限制比總數更容易咬人。Promise.all() 一口氣打 50 個 API,實際上會排成一列一列跑。

// ❌ Looks parallel. Only 6 are actually in flight at a time.
const results = await Promise.all(urls.map((u) => fetch(u)));

不是不能寫,而是要知道它的實際行為,別以為 50 個請求的耗時等於 1 個。

第 1 篇已經談過,這裡收攏成規則:

可以放:常數、編譯好的 regex、import { env } from "cloudflare:workers" 讀 vars/secrets、lazy 初始化的容器變數。

不能放

  • 任何 I/O(fetch、KV、D1……)
  • setTimeout / setInterval
  • 產生隨機值crypto.randomUUID()crypto.getRandomValues())—— 這條最常被忽略,而且會讓 Worker 在啟動時就失敗

可以放但很危險:任何跟使用者/租戶有關的狀態。模組作用域是跨請求共用的,這是第 1 篇講過的資料洩漏第一大來源。


完整程式碼:examples/ch03-execution-model/

Terminal window
cd examples/ch03-execution-model
npm install && npm run cf-typegen && npm run dev

四個實驗:

Terminal window
# 1. ctx 的真實形狀
curl -s localhost:8787/ctx
# 2. request.cf 的本機假資料
curl -s localhost:8787/cf
# 3. 背景工作會不會活下來(本篇核心)
curl -s localhost:8787/reset
curl -s "localhost:8787/fire?mode=none"; sleep 4; curl -s localhost:8787/events
curl -s "localhost:8787/fire?mode=waituntil"; sleep 4; curl -s localhost:8787/events
# 4. passThroughOnException
curl -o /dev/null -w "%{http_code}\n" "localhost:8787/boom"
curl -o /dev/null -w "%{http_code}\n" "localhost:8787/boom?pass=1"

順便:一個文件與 runtime 打架的例子

Section titled “順便:一個文件與 runtime 打架的例子”

寫這篇時想示範 ctx.exports,官方 Context 文件說它 “requires enable_ctx_exports compatibility flag”。照做,加進 wrangler.jsonc

{ "compatibility_flags": ["enable_ctx_exports"] }

結果 Worker 完全起不來

✘ [ERROR] The Workers runtime failed to start.
Runtime stderr: The compatibility flag enable_ctx_exports became the default
as of 2025-11-17 so does not need to be specified anymore.

照著官方文件做,反而把 Worker 弄壞了。 這個 flag 在 2025-11-17 轉為預設,而且 workerd 對「指定已成預設的 flag」是直接報 fatal error,不是警告。

拿掉 flag 之後,ctx.exports 本來就在那裡(前面 /ctx 的輸出可以看到)。

這正是本系列第 3 條原則的實例:程式碼以能否實際跑起來為準,不以文件頁面為準。


這一篇替 LinkForge 生出第一支真的能部署的 Worker:apps/redirector

它是整個系統的熱路徑 —— 每一次短網址被點擊都會經過它。所以設計原則是極簡:查一次、回一個 302、其他事都丟背景。

apps/redirector/src/index.ts
import type { CloudflareBindings } from "./worker-configuration";
// Hardcoded for now. Chapter 08 replaces this with KV.
const LINKS: Record<string, string> = {
cf: "https://developers.cloudflare.com/workers/",
hono: "https://hono.dev/",
};
export default {
async fetch(
request: Request,
env: CloudflareBindings,
ctx: ExecutionContext,
): Promise<Response> {
const slug = new URL(request.url).pathname.slice(1);
const target = LINKS[slug];
if (!target) return new Response("Not found", { status: 404 });
// Best-effort click tracking. Chapter 19 moves this onto a Queue so it
// becomes durable; for now a dropped click is acceptable.
ctx.waitUntil(recordClick(slug, request));
return Response.redirect(target, 302);
},
} satisfies ExportedHandler<CloudflareBindings>;
async function recordClick(slug: string, request: Request): Promise<void> {
console.log(
JSON.stringify({
event: "click",
slug,
country: request.cf?.country ?? null,
colo: request.cf?.colo ?? null,
ua: request.headers.get("user-agent"),
}),
);
}

四個現在就定下來的決策,每個都直接來自本篇:

① 點擊記錄走 ctx.waitUntil(),不 await。 使用者不需要等統計寫完才被導轉。redirect 的 p99 是這個產品的核心指標。

② 但這是 best-effort,而且我們接受。 isolate 被回收、30 秒逾時、寫入失敗,這一筆就沒了。短網址統計掉幾筆不會怎樣。第 19 篇會把它換成 Queue,那時才有重試與持久化 —— 換掉的理由不是「waitUntil 不好」,而是需求變了(要做計費級的精確統計)。

③ log 用 JSON 物件。 第 39 篇會講:只有結構化的 JSON log 才會被 Workers Logs 抽出欄位建索引,Query Builder 才查得到 slugcountry。現在多打幾個字,之後省下一次全面重寫。

request.cf 全部加 ?? null 因為它在 Playground 裡不存在、在本機是假的。

本篇交付物apps/redirector 可部署、可 curl、log 是結構化的。KV 在第 8 篇接上。


項目FreePaid
waitUntil 延長回應後 30 s回應後 30 s
CPU time / 次呼叫10 ms預設 30 s,最高 5 min
Wall time(HTTP)客戶端連著就不限同左
Subrequest / 次呼叫5010,000(可提高至 10M)
同時等待 response header 的連線66
記憶體128 MB / isolate128 MB / isolate

① 不 await 也不 waitUntil 的背景工作

本篇的核心實驗。它不會報錯,只會靜靜地沒跑完。在 code review 裡看到裸露的 somethingAsync() 就要停下來問。

② 把 waitUntil 當成可靠的工作佇列

沒有重試、沒有持久化、30 秒上限。需要保證送達就用 Queues。

③ 讀完 body 之後才 passThroughOnException()

body 是串流,讀掉就沒了,passthrough 送不出去。要保留 fail-open 就先 request.clone()

④ 相信本機的 request.cf

那份假資料長得像真的。所有 geo 邏輯都要部署後驗證。

⑤ 以為 Promise.all 真的全部並行

同時只有 6 條連線在等 response header。

⑥ 照抄文件加上 enable_ctx_exports

該 flag 2025-11-17 起已是預設,指定它會讓 Worker 無法啟動

⑦ 用 Service Worker 語法

// ❌ Deprecated. `env` does not exist in this style — bindings are unreachable.
addEventListener("fetch", (event) => { event.respondWith(handle(event.request)); });

module 語法是唯一選擇。順帶一提,那個 event.waitUntil() 就是今天 ctx.waitUntil() 的前身。


  1. **回應送出 = 執行環境可被回收。**沒進 waitUntil() 的 promise 會被無聲丟棄。
  2. **waitUntil 是 best-effort,不是佇列。**能接受偶爾掉一筆才用它,否則用 Queues。
  3. **worker-configuration.d.ts 比文件頁更新。**九個 handler、七個 ctx 成員,都是型別檔說了算。


下一篇04. Bindings:env 是整個平台的介面 —— binding 為什麼是 capability 而不是連線字串,以及 secret 的正確處理方式。