Chapter 12 · Workers Best Practices
Subchapter 12.2
references/rules.mdMarkdown16 KBView on GitHub
Each rule has an imperative summary, what to check, the correct pattern, and an anti-pattern where applicable. Code examples are plain TypeScript — no MDX components.
When a rule involves config fields or API signatures that may evolve, a Retrieve callout reminds you to check the latest docs or types before flagging. All doc paths are relative to .
https://developers.cloudflare.comSet compatibility_date to today on new projects. Update periodically on existing ones to access new APIs and fixes.
Check: compatibility_date exists. Flag if older than 6 months.
// wrangler.jsonc
{
"compatibility_date": "$today", // Replace with today's date (YYYY-MM-DD)
"compatibility_flags": ["nodejs_compat"]
}Retrieve: current compatibility dates at /workers/configuration/compatibility-dates/.
The nodejs_compat flag enables Node.js built-in modules (node:crypto, node:buffer, node:stream). Many libraries require it. Missing this flag causes cryptic import errors at runtime.
Check: compatibility_flags includes "nodejs_compat".
{
"compatibility_flags": ["nodejs_compat"]
}Never hand-write the Env interface. Run wrangler types to generate it from the wrangler config. Re-run after adding or renaming any binding.
Check: no manually defined Env or interface Env that duplicates wrangler config bindings. Look for satisfies ExportedHandler<Env> pattern on the default export.
// Generated by wrangler types — always matches actual config
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const value = await env.MY_KV.get("key");
return new Response(value);
},
} satisfies ExportedHandler<Env>;Anti-pattern:
// Hand-written Env that drifts from actual bindings
interface Env {
MY_KV: KVNamespace; // What if the binding name changed?
}Secrets must never appear in wrangler config or source code. Use wrangler secret put and access via env at runtime. Non-secret config goes in vars.
Check: no string literals that look like API keys, tokens, or credentials. Verify .env is in .gitignore for local dev.
{
"vars": {
"API_BASE_URL": "https://api.example.com" // Non-secret: OK in config
}
// Secrets set via: wrangler secret put API_KEY
}Anti-pattern:
{
"vars": {
"API_KEY": "sk-live-abc123..." // Secret in version control
}
}Prefer wrangler.jsonc over wrangler.toml. Newer features are JSON-only. JSONC supports comments for documenting config decisions.
Check: project uses wrangler.jsonc (or wrangler.json). Flag wrangler.toml in new projects.
Workers have a 128 MB memory limit. Buffering entire bodies with await response.text() or await request.arrayBuffer() crashes on large payloads. Stream data through using TransformStream or pass response.body directly.
Check: any await response.text(), await response.json(), or await response.arrayBuffer() on data that could be large or unbounded. Small, bounded payloads (known-size JSON, config files) are fine to buffer.
Correct — stream through:
async fetch(request: Request, env: Env): Promise<Response> {
const response = await fetch("https://api.example.com/large-dataset");
return new Response(response.body, response);
}Correct — concatenate multiple streams:
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const urls = ["https://api.example.com/part-1", "https://api.example.com/part-2"];
const { readable, writable } = new TransformStream();
// Track the pipeline promise — don't let it float
ctx.waitUntil((async () => {
for (const url of urls) {
const response = await fetch(url);
if (response.body) {
await response.body.pipeTo(writable, { preventClose: true });
}
}
await writable.close();
})());
return new Response(readable, {
headers: { "Content-Type": "application/octet-stream" },
});
}Anti-pattern:
// Buffers entire body — crashes on large payloads
const response = await fetch("https://api.example.com/large-dataset");
const text = await response.text();
return new Response(text);Retrieve: streaming APIs at /workers/runtime-apis/streams/.
ctx.waitUntil() performs background work (analytics, cache writes, webhooks) after the response is sent. Keeps response fast. 30-second time limit after response.
Check: background work uses ctx.waitUntil(), not inline await. Do not destructure ctx — it loses the this binding and throws “Illegal invocation”.
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const data = await processRequest(request);
ctx.waitUntil(logToAnalytics(env, data));
ctx.waitUntil(updateCache(env, data));
return Response.json(data);
}Anti-pattern:
// Destructuring ctx loses the this binding
const { waitUntil } = ctx; // "Illegal invocation" at runtime
waitUntil(somePromise);Bindings (KV, R2, D1, Queues, Workflows) are direct, in-process references — no network hop, no authentication, no extra latency. Using the Cloudflare REST API from a Worker wastes time and adds complexity.
Check: no fetch("https://api.cloudflare.com/client/v4/...") calls for services available as bindings.
// Binding — direct, zero-cost
const object = await env.MY_BUCKET.get("my-file");Anti-pattern:
// REST API from inside a Worker — unnecessary overhead
const response = await fetch(
"https://api.cloudflare.com/client/v4/accounts/.../r2/buckets/.../objects/my-file",
{ headers: { Authorization: `Bearer ${env.CF_API_TOKEN}` } }
);Long-running, retriable, or non-urgent tasks should not block a request.
Check: long-running work (email sends, webhooks, multi-step processes) is offloaded to Queues or Workflows, not done inline in the fetch handler.
async fetch(request: Request, env: Env): Promise<Response> {
const order = await request.json<{ id: string; type: string }>();
if (order.type === "simple") {
await env.ORDER_QUEUE.send({ orderId: order.id, action: "send-email" });
} else {
await env.FULFILLMENT_WORKFLOW.create({ params: { orderId: order.id } });
}
return Response.json({ status: "accepted" }, { status: 202 });
}Retrieve: /queues/ and /workflows/ for current APIs. For Workflow-specific rules, see Rules of Workflows (opens in a new tab).
Service bindings are zero-cost, bypass the public internet, and support type-safe RPC. Do not call another Worker via its public URL.
Check: Worker-to-Worker calls use env.SERVICE_NAME.method() (RPC) or env.SERVICE_NAME.fetch(), not fetch("https://my-other-worker.example.com/...").
import { WorkerEntrypoint } from "cloudflare:workers";
export class AuthService extends WorkerEntrypoint {
async verifyToken(token: string): Promise<{ userId: string; valid: boolean }> {
return { userId: "user-123", valid: true };
}
}
// Caller Worker
const auth = await env.AUTH_SERVICE.verifyToken(token);Retrieve: verify WorkerEntrypoint import path and signature against latest @cloudflare/workers-types.
Hyperdrive maintains a regional connection pool, eliminating per-request TCP + TLS + auth cost (often 300-500ms). Create a new Client per request — Hyperdrive manages the underlying pool. Requires nodejs_compat.
Check: any new Client() or database connection that uses a direct connection string instead of env.HYPERDRIVE.connectionString.
{
"hyperdrive": [{ "binding": "HYPERDRIVE", "id": "<YOUR_HYPERDRIVE_ID>" }]
}import { Client } from "pg";
async fetch(request: Request, env: Env): Promise<Response> {
const client = new Client({ connectionString: env.HYPERDRIVE.connectionString });
await client.connect();
const result = await client.query("SELECT id, name FROM users LIMIT 10");
return Response.json(result.rows);
}Retrieve: /hyperdrive/ for current configuration and supported databases.
Enable observability in wrangler config before deploying to production. Use head_sampling_rate to control volume and cost. Use structured JSON logging — console.log(JSON.stringify({...})) — so logs are searchable. Use console.error for errors (appears at error severity in the dashboard).
Check: observability.enabled is true in config. Logging uses structured JSON, not string concatenation.
{
"observability": {
"enabled": true,
"logs": { "head_sampling_rate": 1 },
"traces": { "enabled": true, "head_sampling_rate": 0.01 }
}
}// Structured JSON — searchable and filterable
console.log(JSON.stringify({ message: "incoming request", method: request.method, path: url.pathname }));
// Error severity
console.error(JSON.stringify({ message: "request failed", error: e instanceof Error ? e.message : String(e) }));Anti-pattern:
// Unstructured string logs — hard to query
console.log("Got a request to " + url.pathname);Retrieve: /workers/observability/logs/workers-logs/ and /workers/observability/traces/ for current config options.
Workers reuse isolates across requests. Module-level mutable variables cause cross-request data leaks, stale state, and “Cannot perform I/O on behalf of a different request” errors.
Check: no mutable let/var at module scope that gets assigned inside a handler. Pass state through function arguments.
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const userId = request.headers.get("X-User-Id");
const result = await handleRequest(userId, env);
return Response.json(result);
},
} satisfies ExportedHandler<Env>;Anti-pattern:
// Module-level mutable state — leaks between requests
let currentUser: string | null = null;
export default {
async fetch(request: Request, env: Env): Promise<Response> {
currentUser = request.headers.get("X-User-Id"); // Visible to next request
// ...
},
};A Promise that is not awaited, returned, or passed to ctx.waitUntil() is a floating promise. Causes: dropped results, swallowed errors, unfinished work. The runtime may terminate the isolate before it completes.
Check: every fetch(), env.*.put(), env.*.send(), and any other async call is handled. Enable no-floating-promises lint rule.
# ESLint
npx eslint --rule '{"@typescript-eslint/no-floating-promises": "error"}' src/
# oxlint
npx oxlint --deny typescript/no-floating-promises src/// Correct: await when you need the result
const response = await fetch("https://api.example.com/process", { method: "POST", body: JSON.stringify(data) });
// Correct: waitUntil when you don't need the result before responding
ctx.waitUntil(fetch("https://api.example.com/webhook", { method: "POST", body: JSON.stringify(data) }));Anti-pattern:
// Floating promise — result dropped, error swallowed
fetch("https://api.example.com/webhook", { method: "POST", body: JSON.stringify(data) });Workers have a 10ms CPU time limit (Bundled) or 30s (Standard/Unbound). Heavy synchronous work — tight loops, large JSON parsing, compute-intensive crypto — can hit the CPU limit and terminate the request.
Check: compute-heavy operations that run synchronously. Consider breaking work into smaller chunks, offloading to Queues/Workflows, or using WebAssembly for CPU-intensive tasks.
Retrieve: current limits at /workers/platform/limits/.
Use crypto.randomUUID() for unique IDs and crypto.getRandomValues() for random bytes. Math.random() is not cryptographically secure.
For comparing secrets (API keys, HMAC signatures), use crypto.subtle.timingSafeEqual(). Hash both values to a fixed size first — do not short-circuit on length mismatch (leaks length via timing).
Check: no Math.random() for security-sensitive values. Secret comparisons use timingSafeEqual with fixed-size hashing.
// Secure random UUID
const sessionId = crypto.randomUUID();
// Secure random bytes
const tokenBytes = new Uint8Array(32);
crypto.getRandomValues(tokenBytes);
const token = Array.from(tokenBytes).map((b) => b.toString(16).padStart(2, "0")).join("");// Constant-time comparison — hash first to avoid length leak
async function verifyToken(provided: string, expected: string): Promise<boolean> {
const encoder = new TextEncoder();
const [providedHash, expectedHash] = await Promise.all([
crypto.subtle.digest("SHA-256", encoder.encode(provided)),
crypto.subtle.digest("SHA-256", encoder.encode(expected)),
]);
return crypto.subtle.timingSafeEqual(providedHash, expectedHash);
}Anti-pattern:
// Predictable — not cryptographically secure
const token = Math.random().toString(36).substring(2);
// Timing side-channel — leaks information about the expected value
return provided === expected;Retrieve: /workers/runtime-apis/web-crypto/ for current API surface.
passThroughOnException() is a fail-open mechanism that sends requests to the origin when the Worker throws. It hides bugs and makes debugging difficult. Use explicit try/catch with structured error responses.
Check: no ctx.passThroughOnException() calls. Error handling uses try/catch with structured JSON error responses and console.error.
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
try {
const result = await handleRequest(request, env);
return Response.json(result);
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
console.error(JSON.stringify({ message: "unhandled error", error: message, path: new URL(request.url).pathname }));
return Response.json({ error: "Internal server error" }, { status: 500 });
}
}Runs tests inside the Workers runtime with real bindings. Catches issues that Node.js-based tests miss.
Known pitfall: the Vitest pool auto-injects nodejs_compat, so tests pass even if your wrangler config is missing the flag. Always confirm your wrangler.jsonc includes nodejs_compat if your code depends on Node.js built-ins.
Check: test setup uses @cloudflare/vitest-pool-workers. Tests cover nullable returns (e.g., KV .get() returning null).
import { describe, it, expect } from "vitest";
import { env } from "cloudflare:test";
describe("KV operations", () => {
it("should store and retrieve a value", async () => {
await env.MY_KV.put("key", "value");
const result = await env.MY_KV.get("key");
expect(result).toBe("value");
});
it("should return null for missing keys", async () => {
const result = await env.MY_KV.get("nonexistent");
expect(result).toBeNull();
});
});Retrieve: /workers/testing/vitest-integration/ for current setup and configuration.