Cloudflare Workers & edge primitives

SkillCloud & infra

Use when working on Cloudflare's edge platform — wrangler.jsonc bindings, choosing between D1/KV/R2/Durable Objects/Queues, deploying a Worker or SPA via Static Assets, or designing around a Workers runtime limit. NOT generic CI/release (that is `deployment`), NOT Next.js framework wiring (that is `nextjs`), NOT DNS records (that is `domains-dns`).

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the Cloudflare Workers & edge primitives skill

What this skill tells your AI

The instructions your AI receives, as published by ericrisco/rsc-harness in skills/cloudflare/SKILL.md and read by ahel’s review.

The model in one paragraph

A Worker is a fetch handler that runs at the edge. Everything else — R2, D1, KV, Queues, static assets, Durable Objects — is a binding declared in wrangler.jsonc and reached through env. If a resource is not bound, it is not reachable from your code. There is no connection string and no import of the bucket; you wire it in config, type it on Env, and call env.BINDING. Hold this picture and most "how do I access X" questions answer themselves: declare the binding, redeploy, use env.

Quick start

npm create cloudflare@latest (the C3 scaffolder) bootstraps a Worker or a full framework. Use it — it pins a correct compatibility_date and generates types.

npm create cloudflare@latest my-app          # plain Worker
npm create cloudflare@latest my-app -- --framework=react   # Vite + React SPA, GA plugin
cd my-app
npx wrangler dev          # local edge emulation at http://localhost:8787
npx wrangler deploy       # ships Worker + bound assets in one operation

Wrangler is v4 (an incremental release over the v3 rewrite — same config model, updated deps). Pin it: npx wrangler@4.

wrangler.jsonc anatomy

Config may be wrangler.toml, wrangler.json, or wrangler.jsonc. Prefer jsonc so you can comment bindings. Minimum keys: name, main, compatibility_date.

{
  "name": "my-app",
  "main": "src/index.ts",
  // Set to TODAY's date when you start. Why: it pins runtime + flag behavior;
  // bumping it later opts into new defaults (e.g. nodejs_compat auto-enables at 2025-10-01+).
  "compatibility_date": "2026-06-02",
  "compatibility_flags": ["nodejs_compat"],

  // Static Assets — the default way to host a SPA / full-stack app.
  "assets": {
    "directory": "./dist",
    "binding": "ASSETS",                       // env.ASSETS.fetch(request)
    "not_found_handling": "single-page-application"
  },

  // Non-secret config only. Secrets go via `wrangler secret put`, never here.
  "vars": { "API_BASE": "https://api.example.com" },

  "r2_buckets":   [{ "binding": "BUCKET",  "bucket_name": "uploads" }],
  "d1_databases": [{ "binding": "DB", "database_name": "app", "database_id": "<id>" }],
  "kv_namespaces":[{ "binding": "CACHE",  "id": "<namespace-id>" }],
  "queues": {
    "producers": [{ "binding": "JOBS", "queue": "thumbnails" }],
    "consumers": [{ "queue": "thumbnails", "max_batch_size": 10, "max_retries": 3,
                    "dead_letter_queue": "thumbnails-dlq" }]
  }
}

Named environments inherit top-level config and override per env.<name>. See references/wrangler-config.md for the full annotated config, routes, custom domains, and compatibility flags.

Pick the right storage primitive

This is the decision that shapes the architecture. Pick by access pattern and consistency, not by familiarity.

PrimitiveUse forConsistencyHard limitDon't use for
D1Relational app data, per-tenant DBsStrong (single SQLite)10 GB per databaseA single >10 GB monolith; Postgres features (it is SQLite)
KVRead-heavy config, cached lookups, feature flagsEventual (~60s to propagate globally)25 MiB per valueCounters, sessions you read-after-write, anything strongly consistent
R2Files, blobs, uploads, backupsStrong on objectObject storage; no egress feesQuerying/indexing structured data
Durable ObjectsStrongly-consistent coordination, per-entity state, WebSocketsStrong (single-threaded per object)One object = one serialized actorBulk storage; high-fanout reads
QueuesAsync/batch work, decoupling, retriesAt-least-once deliveryBatch ≤100 (default 10)Synchronous request/response

Rule of thumb: need read-after-write? Not KV. Need SQL joins? D1. Need a file? R2. Need a counter or lock? Durable Object. Per-primitive binding config and code, consistency semantics, the complete limits/pricing tables, and Hyperdrive for external Postgres are in references/storage-primitives.md.

Static & full-stack hosting

Workers Static Assets is the recommended way to host SPAs and full-stack apps. The Worker and the assets deploy together.

  • assets.directory — your build output, e.g. ./dist.
  • assets.binding: "ASSETS" — lets the Worker serve files via env.ASSETS.fetch(request).
  • assets.not_found_handling"single-page-application" (serve index.html on miss, for client-side routing) or "404-page".
  • assets.run_worker_first — run the Worker before serving static assets, e.g. so /api/* hits your handler not a file.

Do not use Workers Sites for new projects — it is deprecated in Wrangler v4 and unsupported by the Cloudflare Vite plugin. Migrating off Pages? Pages still works, but new full-stack work targets Workers; the asset-routing rules and the migration checklist are in references/wrangler-config.md.

Bindings in code

Type every binding on Env. Why: without the interface you lose autocompletion and ship undefined binding bugs to the edge.

export interface Env {
  ASSETS: Fetcher;
  DB: D1Database;
  BUCKET: R2Bucket;
  CACHE: KVNamespace;
  JOBS: Queue<{ key: string }>;
}

export default {
  async fetch(req: Request, env: Env): Promise<Response> {
    const url = new URL(req.url);

    if (url.pathname.startsWith("/api/user")) {
      const row = await env.DB.prepare("SELECT * FROM users WHERE id = ?")
        .bind(url.searchParams.get("id")).first();
      return Response.json(row);
    }
    if (req.method === "PUT" && url.pathname.startsWith("/upload/")) {
      await env.BUCKET.put(url.pathname.slice(8), req.body);
      await env.JOBS.send({ key: url.pathname.slice(8) }); // enqueue thumbnail job
      return new Response("ok", { status: 201 });
    }
    const cached = await env.CACHE.get("config", { cacheTtl: 3600 });
    if (url.pathname === "/config" && cached) return new Response(cached);

    return env.ASSETS.fetch(req); // fall through to the SPA
  },
} satisfies ExportedHandler<Env>;

Secrets, env vars, local dev

wrangler secret put STRIPE_KEY      # encrypted, never in wrangler.jsonc or git
echo "STRIPE_KEY=sk_test_..." >> .dev.vars   # local only — gitignore it
  • Secrets via wrangler secret put only. Why: vars in wrangler.jsonc is committed plaintext.
  • .dev.vars supplies secrets for wrangler dev; add it to .gitignore.
  • vars block = non-secret config (API base URLs, feature flags).
  • wrangler dev emulates bindings locally; add --remote to run against real edge resources.

Queues wiring

Producer and consumer are both bindings/handlers — same Worker or different Workers.

// Producer (in fetch): enqueue work
await env.JOBS.send({ key });

// Consumer: a queue() handler on the same module
export default {
  async fetch(/* ... */) { /* ... */ },
  async queue(batch: MessageBatch<{ key: string }>, env: Env): Promise<void> {
    for (const msg of batch.messages) {
      try {
        await processThumbnail(msg.key, env); // make this idempotent — delivery is at-least-once
        msg.ack();
      } catch {
        msg.retry();   // up to max_retries (default 3), then dead-letter
      }
    }
  },
};

Defaults: max_batch_size 10 (max 100), max_retries 3, plus max_batch_timeout. Route exhausted messages to a dead_letter_queue. Make consumers idempotent — at-least-once means a message can arrive twice.

Limits that reshape your design

These numbers are architecture inputs, not trivia. Read them before you design.

  • Subrequests per request are capped — fan-out to dozens of origins fails. Batch, cache in KV, or move work to a Queue consumer.
  • CPU time per request is bounded (raised on the paid plan). Long CPU work → Queue + consumer, or Durable Object alarms.
  • KV value ≤ 25 MiB and eventually consistent — large or write-hot data belongs in R2 or D1.
  • D1 ≤ 10 GB per database — shard per tenant/user (D1 is built for many small DBs), don't grow one monolith.
  • Queue batch ≤ 100 — size max_batch_size to your downstream throughput, not the max.

Plan note: the Workers Paid plan is a $5/mo minimum bundling Workers, Pages Functions, KV, Hyperdrive, and Durable Objects; a Free plan exists with reduced limits (D1 free-tier limits enforced since 2025-02-10).

Anti-patterns

Anti-patternWhy it bitesDo instead
KV for sessions / counters you read after writingEventual consistency: a read after a write can be stale up to ~60sD1 (strong) or a Durable Object (per-entity strong)
Growing one D1 database past 10 GBHard cap; you hit a wall mid-scaleShard per tenant/user; large blobs go to R2
Omitting compatibility_dateRuntime/flag behavior drifts; deploys become non-reproducibleSet it to today's date at project start; bump deliberately
Avoiding R2 over egress costR2 has no egress charges — you're optimizing a cost that doesn't existUse R2 for files/blobs; pay only storage + ops
Workers Sites for a new SPADeprecated in Wrangler v4; unsupported by the Vite pluginassets (Static Assets) with not_found_handling
Secrets in vars or committedPlaintext in repo / config = leakwrangler secret put; .dev.vars (gitignored) for local
Treating D1 like a pooled SQL connectionD1 is accessed over HTTP, not a persistent pool — no transactions across requests, no long-held connectionsOne prepared statement per call; batch with db.batch()
Heavy fan-out to many subrequestsHits the subrequest cap and fails the requestCache in KV, batch, or offload to a Queue consumer

Signals

GitHub stars
82
Forks
3
Last commit
Sep 2026
Hacker News mentions
20

ahel review

  • S4info
    community integration — published by ericrisco, not cloudflare

Automated review, not a security audit. Ruleset v1.

Advanced
Catalog kind
skill
Gateway key
cloudflare-ericrisco
Source
github.com/ericrisco/rsc-harness