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.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- cloudflare-kv-queues Installs to ~/.agents/skills/cloudflare-kv-queues/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install cloudflare-kv-queues The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project cloudflare-kv-queues Installs to .agents/skills/cloudflare-kv-queues/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/cloudflare-kv-queues.zipunzip -o cloudflare-kv-queues.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) cloudflare-kv-queues PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
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
- You need strongly consistent per-entity state or WebSockets: use cloudflare-durable-objects
- The data is relational and needs SQL: use cloudflare-d1
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.
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
- Read
AGENTS.md,wrangler.jsonc(orwrangler.toml) andpackage.json. Note the Wrangler major version: Wrangler 4 is current, and some storage commands default to local state, so pass--remotewhen you mean the real namespace. - List existing bindings:
kv_namespaces,queues.producers,queues.consumers. - Run
npx wrangler typesafter binding changes soEnvmatches the config. - 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
| Need | Use |
|---|---|
| Config, feature flags, rendered fragments, auth lookups read on every request | KV |
| Read-your-own-write, counters, per-user state that changes often | Durable Objects |
| Relational queries, joins, transactions | D1 (see cloudflare-d1) |
| Large blobs, files over a few MB | R2 |
| Deferred work, fan-out, smoothing spikes, retries with backoff | Queues |
| Strict ordering or exactly-once per entity | Durable 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
cacheTtlwindow. - 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.
expirationTtlminimum is 60 seconds.cacheTtlhas 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
metadatasolist()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.idalone. - Ordering is not guaranteed. Carry a version or timestamp and discard stale updates.
- Without
dead_letter_queue, messages that exhaustmax_retriesare dropped. Always configure one and add a consumer or alert on its depth. max_batch_size(up to 100) andmax_batch_timeout(up to 60 s) trade latency for throughput. Usemax_concurrencyto 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 typesproduces bindings matching the code;tsc --noEmitpasses. -
npx wrangler devruns locally with simulated KV and queues; send a test message and confirm the consumer logs an ack. - Unit tests with
@cloudflare/vitest-pool-workerscover: retry path, poison message reaching the DLQ, and duplicate delivery doing no double work. - After deploy,
npx wrangler tailshows 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()returnsnullfor 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
cacheTtland 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 devlocal 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.yaml
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.