Cloudflare KV and Queues skill for Codex

Stop treating KV like a database. Codex checks whether KV actually fits before writing code, then builds key schemes with versioned prefixes, cacheTtl on hot reads and cursor-based listing. For Queues it wires producers and consumers in wrangler.jsonc with explicit ack and retry per message, exponential backoff, a dead-letter queue and duplicate-safe handlers, so at-least-once delivery never becomes double emails.

  • Cloud Platforms
  • $cloudflare-kv-queues
  • 215 lines
  • 2 files
  • ~2,100 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- cloudflare-kv-queues

Installs to ~/.agents/skills/cloudflare-kv-queues/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $cloudflare-kv-queues, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Designs Cloudflare Workers KV and Cloudflare Queues usage around their real limits - eventual consistency, one write per second per key, cacheTtl, list cursors, batch sizes, retries, dead-letter queues and at-least-once delivery - with wrangler.jsonc bindings and typed producer and consumer code. Use when adding a KV namespace or a queue to a Worker, when KV reads return stale data, when writes hit 429s, when a consumer retries forever or loses messages, or when choosing between KV, D1, Durable Objects and Queues.

Use it when

  • A Worker needs a KV namespace or a queue binding added
  • KV reads look stale or writes are rate limited
  • A queue consumer retries forever, drops messages or double-processes them
  • You are choosing between KV, D1, Durable Objects and Queues

Use something else when

How to check the result

Run the consumer under wrangler dev or vitest-pool-workers and confirm success acks, failures retry with backoff, poison messages reach the DLQ and duplicates cause no second side effect.

The mistake it prevents

Throwing from a queue handler retries the entire batch, so messages that already succeeded run again unless each one is acked individually.

~/.agents/skills/cloudflare-kv-queues/SKILL.md

--- name: cloudflare-kv-queues description: Designs Cloudflare Workers KV and Cloudflare Queues usage around their real limits - eventual consistency, one write per second per key, cacheTtl,… license: MIT ---

KV is a read-heavy, eventually consistent cache with a global footprint; Queues is an at-least-once buffer between Workers. A good design never relies on read-after-write in KV, never writes the same KV key more than once a second, and makes every queue consumer idempotent with a dead-letter queue configured.

Before changing anything

  1. Read AGENTS.md, wrangler.jsonc (or wrangler.toml) and package.json. Note the Wrangler major version: Wrangler 4 is current, and some storage commands default to local state, so pass --remote when you mean the real namespace.
  2. List existing bindings: kv_namespaces, queues.producers, queues.consumers.
  3. Run npx wrangler types after binding changes so Env matches the config.
  4. Creating namespaces and queues needs network and an authenticated Wrangler; in a sandbox without network, write the config and code and hand the create commands to the user.

Pick the right store first

NeedUse
Config, feature flags, rendered fragments, auth lookups read on every requestKV
Read-your-own-write, counters, per-user state that changes oftenDurable Objects
Relational queries, joins, transactionsD1 (see cloudflare-d1)
Large blobs, files over a few MBR2
Deferred work, fan-out, smoothing spikes, retries with backoffQueues
Strict ordering or exactly-once per entityDurable Object as a serializer, optionally fed by a queue

If the design needs “write then immediately read the new value from another location”, KV is the wrong tool. Say so before writing code.

KV limits that shape the design

  • Writes propagate globally in up to about 60 seconds; cached reads can be stale for the cacheTtl window.
  • One write per second per key. Bursts on a hot key return 429; shard the key or move the value to a Durable Object.
  • Key up to 512 bytes, value up to 25 MiB, metadata up to 1024 bytes of JSON.
  • expirationTtl minimum is 60 seconds. cacheTtl has a floor of 30 seconds.
  • list() returns at most 1000 keys per call with a cursor; it is also eventually consistent and slow for large prefixes. Do not use list as a query engine.
  • The free plan caps daily writes far lower than reads; check plan limits before designing a write-heavy workload.

KV binding and code

// wrangler.jsonc
{
  "name": "edge-api",
  "main": "src/index.ts",
  "compatibility_date": "2025-09-01",
  "kv_namespaces": [
    { "binding": "CONFIG", "id": "<production namespace id>" }
  ]
}
npx wrangler kv namespace create CONFIG            # prints the id to paste into config
npx wrangler kv key put --binding=CONFIG flags '{"beta":true}' --remote
npx wrangler kv key get --binding=CONFIG flags --remote
type Flags = { beta: boolean };

export default {
  async fetch(req: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    // Hot read path: let the edge cache it for 5 minutes.
    const flags = (await env.CONFIG.get<Flags>("flags", { type: "json", cacheTtl: 300 })) ?? { beta: false };

    // Value plus metadata in one round trip.
    const { value, metadata } = await env.CONFIG.getWithMetadata<{ v: number }>("page:home", "text");

    // Writes: set an expiry for anything derived; never write per request on a hot key.
    ctx.waitUntil(env.CONFIG.put("seen:" + crypto.randomUUID(), "1", { expirationTtl: 3600 }));
    return Response.json({ flags, version: metadata?.v ?? 0, cached: value !== null });
  },
} satisfies ExportedHandler<Env>;

Listing a prefix safely:

async function listAll(ns: KVNamespace, prefix: string): Promise<string[]> {
  const names: string[] = [];
  let cursor: string | undefined;
  do {
    const page = await ns.list({ prefix, cursor, limit: 1000 });
    names.push(...page.keys.map((k) => k.name));
    cursor = page.list_complete ? undefined : page.cursor;
  } while (cursor);
  return names;
}

Key design rules:

  • Namespace keys with a prefix and version: tenant:42:settings:v3. Bumping the version is an instant, consistent “invalidation” that avoids waiting for propagation.
  • Store small metadata (version, content type, updated-at) in metadata so list() can show it without a second read.
  • Keep separate namespaces for preview and production (preview_id), never share one.

Queues: producer

{
  "queues": {
    "producers": [{ "binding": "JOBS", "queue": "email-jobs" }],
    "consumers": [{
      "queue": "email-jobs",
      "max_batch_size": 25,
      "max_batch_timeout": 5,
      "max_retries": 5,
      "retry_delay": 30,
      "dead_letter_queue": "email-jobs-dlq"
    }]
  }
}
npx wrangler queues create email-jobs
npx wrangler queues create email-jobs-dlq
type EmailJob = { id: string; to: string; template: string };

await env.JOBS.send({ id: crypto.randomUUID(), to, template: "welcome" } satisfies EmailJob);
await env.JOBS.sendBatch(jobs.map((body) => ({ body })), { delaySeconds: 10 });

Producer limits: one message up to 128 KB, sendBatch up to 100 messages and 256 KB total. Put large payloads in R2 or KV and enqueue the key.

Queues: consumer

export default {
  async queue(batch: MessageBatch<EmailJob>, env: Env): Promise<void> {
    for (const msg of batch.messages) {
      try {
        if (await env.CONFIG.get(`done:${msg.body.id}`)) { msg.ack(); continue; } // idempotency
        await sendEmail(env, msg.body);
        await env.CONFIG.put(`done:${msg.body.id}`, "1", { expirationTtl: 86400 });
        msg.ack();
      } catch (err) {
        console.error("job failed", msg.id, msg.attempts, err);
        msg.retry({ delaySeconds: Math.min(300, 2 ** msg.attempts * 10) });
      }
    }
  },
} satisfies ExportedHandler<Env, EmailJob>;

Consumer rules:

  • Ack or retry each message explicitly. If the handler throws, the whole batch is retried, including messages that already succeeded.
  • Delivery is at least once. Deduplicate on a business id, not on msg.id alone.
  • Ordering is not guaranteed. Carry a version or timestamp and discard stale updates.
  • Without dead_letter_queue, messages that exhaust max_retries are dropped. Always configure one and add a consumer or alert on its depth.
  • max_batch_size (up to 100) and max_batch_timeout (up to 60 s) trade latency for throughput. Use max_concurrency to protect a fragile downstream API.
  • A KV-based idempotency marker is only best-effort because of propagation delay; for strict once-only side effects, record completion in D1 or a Durable Object.

Verification

  • npx wrangler types produces bindings matching the code; tsc --noEmit passes.
  • npx wrangler dev runs locally with simulated KV and queues; send a test message and confirm the consumer logs an ack.
  • Unit tests with @cloudflare/vitest-pool-workers cover: retry path, poison message reaching the DLQ, and duplicate delivery doing no double work.
  • After deploy, npx wrangler tail shows consumer invocations; queue backlog and DLQ depth are visible in the dashboard.
  • No code path depends on reading a KV value it wrote moments earlier.

Deploying (npx wrangler deploy) changes production; confirm with the user first.

Gotchas

  • get() returns null for a missing key, not an exception. Handle the default.
  • Writing to KV on every request (view counters, rate limits) hits the per-key limit and the write quota. Use a Durable Object or Analytics Engine instead.
  • Deleting a key does not remove it from caches immediately; readers may see the old value until cacheTtl and propagation pass.
  • A queue consumer Worker must be deployed for messages to drain. Renaming the consumer Worker without updating the queue leaves messages stuck.
  • wrangler dev local KV is empty and separate from production; seed it explicitly.

Output contract

When designing or reviewing, report:

## KV / Queues design: <feature>
- Store choice and why (consistency need, write rate per key)
- Bindings added (name, namespace or queue, env)
- Key scheme / message schema with version field
- Retry policy: max_retries, delay, DLQ name, idempotency key
- Limits checked: value size, writes per key per second, batch size
- Not verified: <anything that needs network or a deploy>

What the zip contains

SKILL.md plus the interface file below. Nothing runs on install.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "Cloudflare KV and Queues"
  short_description: "Stop treating KV like a database"
  default_prompt: "$cloudflare-kv-queues Add a Cloudflare Queue so signup emails are sent in the background from my Worker"

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc