Cloud Platforms No. 182

Cloudflare Workers skill for Claude Code

Right storage per binding, typed env, one-command rollback. Claude configures Workers with explicit compatibility dates, generated binding types and per-environment bindings, then picks KV, D1, R2, Durable Objects or Queues from the access pattern rather than habit. Code stays inside CPU and memory limits, tests run in the real Workers runtime via Vitest, and releases go out as versions with traffic splitting and a known rollback.

  • namecloudflare-workers
  • size203 lines · ≈2,350 tokens
  • files1
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Builds and deploys Cloudflare Workers with Wrangler - wrangler.jsonc configuration, compatibility dates and nodejs_compat, typed bindings generated by wrangler types, KV, D1, R2, Durable Objects, Queues and Hyperdrive, secrets and environments, local development with wrangler dev, Vitest in the Workers runtime, platform limits, gradual deployments and rollback, and observability. Use when creating a Worker, adding a binding, choosing between KV, D1, R2 and Durable Objects, hitting CPU or subrequest limits, or debugging behavior that differs between local and deployed Workers.

Try asking

  • Set up a Worker with D1, R2 and a queue consumer using wrangler.jsonc
  • Should these counters live in KV, D1 or a Durable Object?
  • Our Worker works in wrangler dev but fails after deploy. Investigate

Use it when

  • Creating or configuring a Cloudflare Worker
  • Adding KV, D1, R2, Durable Objects, Queues or Hyperdrive bindings
  • Hitting CPU, memory or subrequest limits
  • Local behavior differs from the deployed Worker

Skip it when

  • You need routing, validators and RPC types in app code — use hono-edge
  • The question is R2 presigned uploads and bucket policy — use s3-object-storage

How to check the result

wrangler types and tsc pass, Vitest runs green in the Workers pool, and after versions deploy the tail shows no exceptions with CPU time well under the limit.

The common mistake it prevents

Using KV for counters, locks or read-after-write data, where eventual consistency returns stale values; that state belongs in a Durable Object.

~/.claude/skills/cloudflare-workers/SKILL.md

--- name: cloudflare-workers description: Builds and deploys Cloudflare Workers with Wrangler - wrangler.jsonc configuration, compatibility dates and nodejs_compat, typed bindings… license: MIT ---

A well-built Worker declares every binding in Wrangler config with generated types, uses each storage product for what it is good at, stays inside CPU and subrequest limits by design, keeps secrets out of config, runs tests in the real Workers runtime, and deploys through versions so a bad release rolls back in one command.

Workflow

- [ ] 1. Read wrangler.jsonc/toml: name, main, compatibility_date/flags, bindings, envs
- [ ] 2. Pick storage per access pattern (table below)
- [ ] 3. Declare bindings; run wrangler types; code against typed env
- [ ] 4. Local dev with wrangler dev (local bindings); seed D1/KV as needed
- [ ] 5. Tests with @cloudflare/vitest-pool-workers
- [ ] 6. Secrets per environment; deploy a version; promote gradually
- [ ] 7. Observe with logs/tail; check CPU time and errors

Configuration

// wrangler.jsonc
{
  "$schema": "node_modules/wrangler/config-schema.json",
  "name": "orders-api",
  "main": "src/index.ts",
  "compatibility_date": "2026-09-01",
  "compatibility_flags": ["nodejs_compat"],
  "observability": { "enabled": true },
  "kv_namespaces": [{ "binding": "CACHE", "id": "<namespace-id>" }],
  "d1_databases": [{ "binding": "DB", "database_name": "orders", "database_id": "<uuid>", "migrations_dir": "migrations" }],
  "r2_buckets": [{ "binding": "FILES", "bucket_name": "orders-files" }],
  "durable_objects": { "bindings": [{ "name": "ROOMS", "class_name": "Room" }] },
  "migrations": [{ "tag": "v1", "new_sqlite_classes": ["Room"] }],
  "queues": {
    "producers": [{ "binding": "JOBS", "queue": "orders-jobs" }],
    "consumers": [{ "queue": "orders-jobs", "max_batch_size": 10, "max_retries": 5, "dead_letter_queue": "orders-jobs-dlq" }]
  },
  "vars": { "APP_ENV": "production" },
  "env": {
    "staging": { "vars": { "APP_ENV": "staging" }, "d1_databases": [{ "binding": "DB", "database_name": "orders-staging", "database_id": "<uuid>" }] }
  }
}
  • compatibility_date: set to the date you start a project and bump deliberately; it changes runtime behavior.
  • nodejs_compat enables Node built-ins (node:crypto, node:buffer, AsyncLocalStorage, many npm packages). Without it, Node-dependent libraries fail at runtime.
  • Bindings are not inherited by named environments: repeat them under env.<name>.
  • Run npx wrangler types after changing bindings; it generates the Env interface (worker-configuration.d.ts) so env.DB is typed. Commit or regenerate in CI.

Storage choice

NeedUseKey properties
Read-heavy config, flags, cached API responses, sessionsKVGlobal, eventually consistent (changes can take up to about 60 s to propagate), low write rate per key (about 1/s)
Relational data, queries, transactions per requestD1 (SQLite)Single primary per database with read replication option; batch statements; size-limited per DB, so shard per tenant at scale
Files, blobs, user uploads, large objectsR2S3-compatible, no egress fees, strong consistency
Coordination, counters, rate limits, websockets, per-entity stateDurable ObjectsOne instance per ID, single-threaded, strongly consistent SQLite storage, alarms
Async work, retries, batchingQueuesAt-least-once, batching, DLQ
Existing Postgres/MySQLHyperdrivePools and caches connections from Workers to your database
Multi-step durable processesWorkflowsSteps with retries and sleeps, state persisted

Worker code

export default {
  async fetch(req: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    const url = new URL(req.url);
    if (url.pathname.startsWith("/orders/") && req.method === "GET") {
      const id = url.pathname.split("/")[2];
      const cached = await env.CACHE.get(`order:${id}`, "json");
      if (cached) return Response.json(cached);
      const row = await env.DB.prepare("SELECT id, total, status FROM orders WHERE id = ?").bind(id).first();
      if (!row) return new Response("not found", { status: 404 });
      ctx.waitUntil(env.CACHE.put(`order:${id}`, JSON.stringify(row), { expirationTtl: 60 }));
      return Response.json(row);
    }
    return new Response("not found", { status: 404 });
  },

  async queue(batch: MessageBatch<{ orderId: string }>, env: Env): Promise<void> {
    for (const msg of batch.messages) {
      try { await processOrder(env, msg.body.orderId); msg.ack(); }
      catch { msg.retry({ delaySeconds: 30 }); }
    }
  },

  async scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext) {
    ctx.waitUntil(sweepExpired(env));
  },
} satisfies ExportedHandler<Env>;
  • Always .bind() parameters in D1; never interpolate. Use env.DB.batch([...]) for multiple statements in one round trip (executed as a transaction).
  • ctx.waitUntil extends the request lifetime for background work after the response; it is not durable. Use Queues for anything that must happen.
  • No global mutable state across requests for correctness: isolates are reused unpredictably and not shared across locations.

Durable Object with SQLite storage:

import { DurableObject } from "cloudflare:workers";

export class Room extends DurableObject<Env> {
  async increment(): Promise<number> {
    this.ctx.storage.sql.exec("CREATE TABLE IF NOT EXISTS c (k TEXT PRIMARY KEY, v INTEGER)");
    this.ctx.storage.sql.exec("INSERT INTO c VALUES ('n', 1) ON CONFLICT(k) DO UPDATE SET v = v + 1");
    return this.ctx.storage.sql.exec("SELECT v FROM c WHERE k = 'n'").one().v as number;
  }
}
// caller: const stub = env.ROOMS.get(env.ROOMS.idFromName(roomId)); await stub.increment();

Use the WebSocket Hibernation API (this.ctx.acceptWebSocket(ws)) for many idle sockets.

Limits to design around

Check the current limits page for exact numbers; these shape architecture:

  • CPU time per invocation: about 10 ms on Free; paid plans default to 30 s and can be raised ("limits": { "cpu_ms": 300000 }). Wall time waiting on I/O does not count.
  • Memory: 128 MB per isolate. Stream large bodies instead of buffering.
  • Subrequests (fetch, binding calls) per invocation are capped and differ by plan.
  • Script size limits after compression differ by plan; heavy dependencies hurt startup.
  • D1 and KV have per-value and per-database size limits.

CPU-heavy work (image processing, large JSON transforms) belongs in Queues consumers, Containers, or another service.

Local development and tests

npx wrangler dev                                  # local runtime (workerd) with local bindings
npx wrangler dev --remote                         # run on Cloudflare's edge with real resources
npx wrangler d1 migrations create orders add_status
npx wrangler d1 migrations apply orders --local
npx wrangler d1 execute orders --local --command "SELECT count(*) FROM orders"
// vitest.config.ts
import { defineWorkersConfig } from "@cloudflare/vitest-pool-workers/config";
export default defineWorkersConfig({ test: { poolOptions: { workers: { wrangler: { configPath: "./wrangler.jsonc" } } } } });

// test/index.test.ts
import { SELF, env } from "cloudflare:test";
it("returns 404 for unknown order", async () => {
  const res = await SELF.fetch("https://example.com/orders/nope");
  expect(res.status).toBe(404);
});

Local secrets go in .dev.vars (gitignored), mirroring production secret names.

Secrets and deploys

npx wrangler secret put STRIPE_KEY                     # production
npx wrangler secret put STRIPE_KEY --env staging
npx wrangler d1 migrations apply orders --remote       # before deploying code that needs it
npx wrangler deploy --env staging
npx wrangler versions upload                           # upload without serving traffic
npx wrangler versions deploy                           # interactive: split traffic between versions
npx wrangler rollback                                  # previous version
npx wrangler tail orders-api --format pretty           # live logs

Production deploys, remote D1 migrations, and deleting KV namespaces, D1 databases or R2 buckets require explicit user confirmation. Remote D1 migrations are not automatically reversible; back up first with wrangler d1 export orders --remote --output backup.sql or rely on D1 Time Travel restore.

Gotchas

  • wrangler dev local mode uses local copies of D1/KV/R2; data differs from production.
  • KV get right after put from another location can return stale data; never use KV for counters or locks (use Durable Objects).
  • Responses with Set-Cookie or authorization are not cached by default; use the Cache API deliberately with explicit keys.
  • fetch to your own zone from a Worker on the same route can loop; use service bindings for Worker-to-Worker calls.
  • Date and timers advance only during I/O inside a request (Spectre mitigation), so Date.now() differences measure I/O, not CPU.

Verification

  1. npx wrangler types is clean, tsc --noEmit passes, and vitest runs green in the Workers pool.
  2. wrangler dev serves expected responses with local bindings; remote migrations applied before code that needs them.
  3. After versions deploy, wrangler tail shows no exceptions and the dashboard shows CPU time well under the limit; rollback command known and tested on staging.

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc