Files
dotfiles/dot_agents/skills/workers-best-practices/references/rules.md
T
Prad Nukala 6406208b35 shell: fish functions -> ~/.local/bin executables + bash/zsh ports; manage ~/.agents
- 16 cross-shell utilities converted to sh/bash chezmoi executables:
  ai c2p cp-appid extract_agents fab gdc gh-sonr-org-migrate ghg-clone
  ghn gho git-summary launch_omp_livediff_overlay lc-elir mdcp mdp pj
  (ghg-clone not named ghg: unrelated cargo binary owns that name)
- cwd/history helpers stay per-shell: yy c cd clip-last + ghg/ghn/gho
  cd-wrappers in bash/functions/ and zsh/functions/, loaded by rc files
- fish keeps only shell-state functions; converted ones now hit PATH
- cp-appid ignore gating moved to .local/bin path
- dot_agents/skills: 82 skills materialized from dotstate storage so
  dot_claude skill symlinks resolve on fresh bootstrap; dotstate
  profile/symlinks.json released .agents/skills .bashrc .zshrc
2026-08-10 15:15:06 -04:00

16 KiB

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.

// 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".

{
  "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<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?
}

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.

{
  "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
  }
}

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:

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/.

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".

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);

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.

// 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}` } }
);

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.

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.

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/...").

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.

{
  "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.


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.

{
  "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.


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.

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
    // ...
  },
};

Always await or waitUntil Promises

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) });

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.

// 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.

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.

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 });
  }
}

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).

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.