# Workers Best Practices — Rules 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.com`. --- ## Configuration ### Keep compatibility_date current Set `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. ```jsonc // 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/`. ### Enable nodejs_compat 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"`. ```jsonc { "compatibility_flags": ["nodejs_compat"] } ``` ### Generate binding types with wrangler types 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` pattern on the default export. ```ts // Generated by wrangler types — always matches actual config export default { async fetch(request: Request, env: Env): Promise { const value = await env.MY_KV.get("key"); return new Response(value); }, } satisfies ExportedHandler; ``` Anti-pattern: ```ts // Hand-written Env that drifts from actual bindings interface Env { MY_KV: KVNamespace; // What if the binding name changed? } ``` ### Store secrets with wrangler secret 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. ```jsonc { "vars": { "API_BASE_URL": "https://api.example.com" // Non-secret: OK in config } // Secrets set via: wrangler secret put API_KEY } ``` Anti-pattern: ```jsonc { "vars": { "API_KEY": "sk-live-abc123..." // Secret in version control } } ``` ### Use wrangler.jsonc for config 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. --- ## Request & Response Handling ### Stream request and response bodies 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: ```ts async fetch(request: Request, env: Env): Promise { const response = await fetch("https://api.example.com/large-dataset"); return new Response(response.body, response); } ``` Correct — concatenate multiple streams: ```ts async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise { 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: ```ts // 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/`. ### Use waitUntil for work after the response `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". ```ts async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise { const data = await processRequest(request); ctx.waitUntil(logToAnalytics(env, data)); ctx.waitUntil(updateCache(env, data)); return Response.json(data); } ``` Anti-pattern: ```ts // Destructuring ctx loses the this binding const { waitUntil } = ctx; // "Illegal invocation" at runtime waitUntil(somePromise); ``` --- ## Architecture ### Use bindings for Cloudflare services, not REST APIs 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. ```ts // Binding — direct, zero-cost const object = await env.MY_BUCKET.get("my-file"); ``` Anti-pattern: ```ts // 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}` } } ); ``` ### Use Queues and Workflows for async and background work Long-running, retriable, or non-urgent tasks should not block a request. - **Queues**: decouple producer from consumer. Fan-out, buffering/batching, simple single-step background jobs. At-least-once delivery. - **Workflows**: multi-step durable execution. Each step's return value is persisted; only failed steps retry. Can run for hours/days/weeks. - **Both together**: Queue buffers high-throughput entry, consumer creates Workflow instances for complex processing. **Check**: long-running work (email sends, webhooks, multi-step processes) is offloaded to Queues or Workflows, not done inline in the fetch handler. ```ts async fetch(request: Request, env: Env): Promise { 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](https://developers.cloudflare.com/workflows/build/rules-of-workflows/). ### Use service bindings for Worker-to-Worker communication 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/...")`. ```ts 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`. ### Use Hyperdrive for external database connections 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`. ```jsonc { "hyperdrive": [{ "binding": "HYPERDRIVE", "id": "" }] } ``` ```ts import { Client } from "pg"; async fetch(request: Request, env: Env): Promise { 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. --- ## Observability ### Enable Workers Logs and Traces 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. ```jsonc { "observability": { "enabled": true, "logs": { "head_sampling_rate": 1 }, "traces": { "enabled": true, "head_sampling_rate": 0.01 } } } ``` ```ts // 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: ```ts // 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. --- ## Code Patterns ### Do not store request-scoped state in global scope 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. ```ts export default { async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise { const userId = request.headers.get("X-User-Id"); const result = await handleRequest(userId, env); return Response.json(result); }, } satisfies ExportedHandler; ``` Anti-pattern: ```ts // Module-level mutable state — leaks between requests let currentUser: string | null = null; export default { async fetch(request: Request, env: Env): Promise { currentUser = request.headers.get("X-User-Id"); // Visible to next request // ... }, }; ``` ### Always await or waitUntil Promises A Promise that is not `await`ed, `return`ed, 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. ```bash # ESLint npx eslint --rule '{"@typescript-eslint/no-floating-promises": "error"}' src/ # oxlint npx oxlint --deny typescript/no-floating-promises src/ ``` ```ts // 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: ```ts // Floating promise — result dropped, error swallowed fetch("https://api.example.com/webhook", { method: "POST", body: JSON.stringify(data) }); ``` ### Be aware of platform limits 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/`. --- ## Security ### Use Web Crypto for secure token generation 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. ```ts // 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(""); ``` ```ts // Constant-time comparison — hash first to avoid length leak async function verifyToken(provided: string, expected: string): Promise { 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: ```ts // 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. ### Explicit error handling over passThroughOnException `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`. ```ts async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise { 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 }); } } ``` --- ## Development & Testing ### Test with @cloudflare/vitest-pool-workers 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`). ```ts 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.