Workers runtime mental model: lifecycle, context, limit

fetch handler, ExecutionContext, waitUntil, giới hạn subrequest, CPU vs wall time, cold start thực tế. 6 ngộ nhận khi dev từ Node/Lambda sang Workers. Code mẫu từ blog này.

· 7 phút đọc · Read in English
Workers runtime mental model: vòng đời 6 bước của fetch(request, env, ctx) từ PoP nhận → isolate warm/cold → subrequest song song → response → waitUntil chạy nền, kèm giới hạn CPU/wall time

TL;DR

Workers runtime là một V8 isolate chạy handler dạng fetch(request, env, ctx). Request đi qua 6 bước: PoP nhận → tìm isolate (warm hoặc cold start ~5ms) → handler chạy → subrequest song song → response trả về → waitUntil chạy nền.

Điều hay sai nhất khi dev từ Lambda sang:

CPU time khác wall time. await fetch() không tính CPU time, nhưng một vòng for tính toán nặng thì có. Free plan cho 10ms CPU, Paid plan mặc định 50ms. Vượt = request bị kill.

Bài này đi qua: lifecycle chi tiết, 3 object truyền vào handler (Request, env, ctx), giới hạn từng plan, waitUntil cho task nền, passThroughOnException làm phương án dự phòng, và 6 ngộ nhận phổ biến. Code mẫu lấy từ Worker đang chạy blog này.


Dành cho ai

  • Dev đã đọc Part 1, giờ sắp viết handler đầu tiên.
  • Người đang build Worker mà gặp lỗi kiểu Worker exceeded CPU time limit hoặc Too many subrequests.
  • Ai muốn hiểu vì sao console.log trong vòng lặp for đôi khi nuốt luôn ngân sách CPU.

Nên biết trước: Promise / async-await trong JS, Fetch API (Request, Response, Headers).

Sau bài này bạn sẽ:

  • Hiểu lifecycle 6 bước của một request.
  • Phân biệt CPU time và wall time.
  • Biết khi nào dùng ctx.waitUntil vs ctx.passThroughOnException.
  • Tránh được 6 ngộ nhận khiến handler rò rỉ state hoặc vượt giới hạn.

Bài này không nói về gì

  • Bindings cụ thể (D1, KV, R2): Part 3-8.
  • Hono / router framework: Part 9. Bài này dùng fetch thuần.
  • Wrangler, dev local: Part 4.
  • Giá cả chi tiết: Part 19.

Anatomy: handler chỉ là một function

Một module Worker chuẩn export một object với tối đa 3 handler:

// src/index.ts
export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    return new Response("Hello from the edge");
  },

  async scheduled(event: ScheduledEvent, env: Env, ctx: ExecutionContext): Promise<void> {
    // chạy theo cron trigger trong wrangler.jsonc
  },

  async queue(batch: MessageBatch, env: Env, ctx: ExecutionContext): Promise<void> {
    // consumer cho Queues
  }
} satisfies ExportedHandler<Env>;

3 handler, 3 đầu vào khác nhau, nhưng cùng pattern:

  • Đối số đầu là event kích hoạt (Request, ScheduledEvent, MessageBatch).
  • env chứa mọi binding (DB, KV, R2, secrets).
  • ctxExecutionContext, cho bạn waitUntilpassThroughOnException.

Không app.listen(port). Không chuỗi middleware Express cấp framework. Không process.env. Chỉ có một function được gọi cho mỗi request, và nền tảng lo phần còn lại.


Request lifecycle: 6 bước

Workers request lifecycle: request đến PoP gần nhất bằng anycast, router chọn Worker, V8 isolate warm hoặc cold start, fetch handler chạy, subrequest song song sang D1/KV/R2/origin/Workers AI, response trả về client, waitUntil chạy task nền sau khi response đã đi.

① Request đến PoP

DNS của example.com trỏ về Cloudflare. Anycast routing đưa request đến PoP gần người dùng nhất (thường là < 50ms RTT).

② Tìm isolate

Cloudflare tìm một isolate đã compile sẵn code của Worker này:

  • Isolate warm: tái sử dụng, handler chạy sau ~50µs. Đây là phần lớn request ở production.
  • Cold start: nếu chưa có isolate nào ở PoP này, V8 compile script. Mất ~5ms.

Không có chuyện “scale to zero” chờ 15 phút rồi lại cold start như Lambda. Cloudflare giữ isolate warm khá lâu, và cold start rẻ nên không phải ưu tiên tối ưu.

③ fetch handler chạy

Handler nhận (request, env, ctx), làm gì đó, return một Response.

async fetch(request, env, ctx) {
  const url = new URL(request.url);
  if (url.pathname === "/api/posts") {
    const rows = await env.DB.prepare("SELECT * FROM posts").all();
    return Response.json(rows);
  }
  return new Response("Not found", { status: 404 });
}

④ Subrequest song song

Mỗi await env.DB.prepare(...) hay await fetch("https://api.com/...") là một subrequest. Có giới hạn:

  • Free plan: 50 subrequest / request.
  • Paid plan: 1000 subrequest / request.

Binding (D1, KV, R2, Queues, AI) đều tính là subrequest. Nếu lặp qua 100 dòng D1 và mỗi dòng gọi thêm KV, bạn cháy ngân sách ngay.

⑤ Response trả về

Khi handler return một Response, Cloudflare stream body về client. Sau điểm này, response đã rời isolate.

⑥ waitUntil chạy nền

ctx.waitUntil(promise) đăng ký một Promise mà nền tảng sẽ đợi sau khi response đã đi. Dùng cho logging, analytics, làm ấm cache — việc không cần chặn response.

async fetch(request, env, ctx) {
  const response = await handleRequest(request, env);

  // Log bất đồng bộ, không chặn response
  ctx.waitUntil(
    env.DB.prepare("INSERT INTO views (slug, ts) VALUES (?, ?)")
      .bind(slug, Date.now())
      .run()
  );

  return response;
}

Người dùng thấy response ngay; việc ghi log chạy xong sau đó.


3 object trong handler

Request

Chuẩn Fetch API, có thêm thuộc tính cf chứa metadata edge:

async fetch(request: Request, env: Env, ctx: ExecutionContext) {
  const url = new URL(request.url);
  const method = request.method;
  const body = await request.json();

  // Cloudflare-specific
  const country = request.cf?.country;        // 'VN', 'US', ...
  const colo = request.cf?.colo;              // 'SIN', 'HKG' — PoP code
  const tlsVersion = request.cf?.tlsVersion;  // 'TLSv1.3'
  const botScore = request.cf?.botManagement?.score;
}

Với analytics + định tuyến theo địa lý, request.cf đủ dùng mà không cần dịch vụ bên ngoài.

env

Chứa binding + secret + var từ wrangler.jsonc:

interface Env {
  DB: D1Database;
  KV: KVNamespace;
  BUCKET: R2Bucket;
  QUEUE: Queue;
  AI: Ai;
  VECTORIZE: VectorizeIndex;
  // secrets
  RESEND_API_KEY: string;
  // plain vars
  SITE_ORIGIN: string;
}

Không có process.env. Không dotenv. Secret được inject lúc compile + bảo vệ bằng Wrangler.

ctx (ExecutionContext)

Có 2 method:

  • ctx.waitUntil(promise): kéo dài lifecycle sau response.
  • ctx.passThroughOnException(): nếu handler throw, Cloudflare chuyển request về origin (áp dụng khi Worker chạy trên zone có origin).

CPU time vs Wall time

Đây là nguồn nhầm lẫn lớn nhất.

Wall time: thời gian thực tính từ lúc handler bắt đầu đến khi kết thúc, gồm cả thời gian await đợi I/O. Tối đa 30s cho mọi plan.

CPU time: chỉ tính thời gian CPU thật sự bận xử lý code. await fetch() đang đợi network → CPU rảnh → không tính.

async fetch(request, env) {
  await fetch("https://slow-api.com/data"); // 2s wall, ~0ms CPU
  await new Promise(r => setTimeout(r, 5000)); // 5s wall, ~0ms CPU
  for (let i = 0; i < 1e7; i++) { sum += i; }  // 50ms wall, 50ms CPU
}

3 dòng đầu hoàn toàn OK dù wall time ~7s. Dòng 3 mới nguy hiểm.

Giới hạn theo plan

Bảng giới hạn runtime Workers: CPU time, wall time, memory, số subrequest, kích thước request body, kích thước response, kích thước script Worker, so sánh Free plan, Paid Bundled, và Paid Unbound.

Điểm cần nhớ:

  • Free plan 10ms CPU: chỉ đủ cho API đơn giản, nặng I/O. Parse JSON lớn, regex nặng, verify JWT cryptographic → cháy.
  • Paid Bundled 50ms CPU (mặc định), cho phép lên 30s qua config: đủ cho 99% trường hợp sử dụng.
  • Paid Unbound 30s CPU: cho tải nặng tính toán nhưng trả phí theo CPU time chứ không bundled.

Blog này chạy Paid Bundled 50ms. Mình đã phải tách một handler sinh embedding cho 200 tài liệu vì nó vượt 50ms. Giờ tách qua Queue, mỗi message xử lý 1 tài liệu.

Khi nào gặp giới hạn CPU

  • Verify JWT với RSA signature cho mọi request.
  • Parse CSV hoặc markdown lớn.
  • Chạy regex nặng trên body text.
  • Encode/decode base64 cho blob nhị phân lớn.
  • Pagefind index trong worker.

Cách xử lý: cache (KV, Cache API), đẩy sang Queue, hoặc dùng bindings chuyên (AI, Stream).


waitUntil: pattern quan trọng nhất

waitUntil là cách chính thức để chạy việc “sau khi response đã trả”. Dùng cho mọi task không chặn người dùng:

Ghi log page view

async fetch(request, env, ctx) {
  const response = await render(request, env);

  ctx.waitUntil(logView(request, env));

  return response;
}

async function logView(request: Request, env: Env) {
  const slug = new URL(request.url).pathname;
  await env.DB.prepare(
    "INSERT INTO post_views_daily (slug, day, count) VALUES (?, ?, 1) " +
    "ON CONFLICT(slug, day) DO UPDATE SET count = count + 1"
  ).bind(slug, today()).run();
}

Làm ấm cache sau miss

async fetch(request, env, ctx) {
  const cached = await env.KV.get(key);
  if (cached) return new Response(cached);

  const fresh = await computeExpensive();
  ctx.waitUntil(env.KV.put(key, fresh, { expirationTtl: 3600 }));

  return new Response(fresh);
}

Người dùng không chờ KV ghi. Handler return ngay, KV ghi chạy nền.

Sự kiện analytics

ctx.waitUntil(
  env.ANALYTICS.writeDataPoint({
    blobs: [slug, userAgent],
    doubles: [readingTime],
    indexes: [country],
  })
);

Gotcha quan trọng

waitUntil không vượt qua wall time 30s của request. Nếu task nền mất 1 phút, bị kill. Với task dài dùng Queue thay vì waitUntil.


passThroughOnException: phương án dự phòng về origin

Khi Worker chạy trên zone có origin thật (không phải workers.dev), bạn có thể dùng phương án dự phòng nếu handler throw:

async fetch(request, env, ctx) {
  ctx.passThroughOnException();

  try {
    return await handleRequest(request, env);
  } catch (err) {
    // ném lại để CF fallback về origin
    throw err;
  }
}

Hữu ích cho giai đoạn đầu khi mới thêm Worker trước origin — Worker crash không làm site down, request đi thẳng xuống origin.

Blog này không dùng vì không có origin backing (toàn bộ là Workers Assets). Nhưng nếu bạn có nginx/rails ở sau và Worker chỉ là middleware CDN, đây là lớp an toàn bắt buộc.


6 ngộ nhận phổ biến

① “Biến mức module là cache”

// SAI
let cache = new Map();

export default {
  async fetch(request, env) {
    if (cache.has(key)) return new Response(cache.get(key));
    // ...
  }
};

Isolate có thể bị huỷ bất cứ lúc nào. Cache này không đảm bảo tồn tại giữa 2 request. Đôi khi hit, đôi khi miss. Dùng KV hoặc Cache API.

② “Cold start là vấn đề”

Dev Lambda quen tối ưu cold start bằng provisioned concurrency. Workers cold start ~5ms, không cần. Dành công sức cho CPU time và subrequest thay vì cold start.

③ “await trong vòng lặp for OK”

// SAI — chạy tuần tự, cháy wall time
for (const id of ids) {
  const row = await env.DB.prepare("SELECT * FROM posts WHERE id = ?").bind(id).first();
  results.push(row);
}

// ĐÚNG — song song
const rows = await Promise.all(
  ids.map(id => env.DB.prepare("SELECT * FROM posts WHERE id = ?").bind(id).first())
);

Với D1 thì thậm chí dùng WHERE id IN (?) tốt hơn. Nhưng nguyên tắc chung: song song, không tuần tự.

④ “waitUntil chạy mãi”

Wall time 30s vẫn áp dụng cho waitUntil. Task nền > 30s phải qua Queue.

⑤ “env là global”

env được inject mỗi request, không phải global. Không thể import { env } from "..." ở mức module. Truyền qua đối số hàm.

⑥ “console.log mất phí”

console.log không tốn CPU time đáng kể, nhưng mỗi dòng log sẽ tốn Tail Workers nếu bạn bật. Đừng spam log trong đường nóng. Dùng log có cấu trúc ở đường lỗi.


Production checklist

  • Handler không đụng state mutable ở mức module (cache, counter…).
  • Mọi task không chặn người dùng được bọc ctx.waitUntil().
  • Lặp qua collection → Promise.all, không for await.
  • Task nặng CPU (crypto, parse lớn, regex nặng) được đẩy qua Queue hoặc cache.
  • Số subrequest được kiểm soát (< 50 Free, < 1000 Paid).
  • Xử lý lỗi không ném ra nền tảng (trả JSON response 5xx thay vì để crash).
  • Nếu có origin backing, bật ctx.passThroughOnException().
  • Không dùng setTimeout dài hơn giới hạn wall time.

Kết

Workers runtime về bản chất là: một function chạy trong V8 isolate, nhận request, trả response, tất cả trong 30s wall time và 50ms CPU time (Paid mặc định). Binding là cách duy nhất nói chuyện với bên ngoài. ctx.waitUntil là cách duy nhất chạy nền hợp lệ.

Chấp nhận mental model này, mọi tối ưu và quyết định thiết kế sau đó có chỗ bám.

Part 3 tới đi vào mental model 3 primitive binding: Request → Identity → Storage. Đó là khung chung cho mọi Worker, từ API đơn giản đến full-stack app.


Tham khảo