Quick overview
Cloudflare provides two important primitives for serverless workloads beyond D1 Database:
- Workers KV: globally distributed key-value store — fast reads, eventually consistent, ideal for config, cache, and sessions
- Cloudflare Queues: message queue — push jobs into a queue, a separate Worker processes them asynchronously
Both are only accessible via binding inside Cloudflare Workers or Pages Functions. There is no connection string, no public HTTP endpoint, and no way to use them from a server outside the Cloudflare platform.
Free tier is more than enough for personal projects
The most important thing to know upfront: both KV and Queue have generous free tiers that easily cover side projects and small apps.
Real-world examples on Workers Free:
| Project | KV reads/day | KV writes/day | Queue ops/month |
|---|---|---|---|
| Personal blog, 500 visitors/day | ~5,000 | ~10 | — |
| App sending welcome emails, 50 sign-ups/day | ~500 | ~50 | ~1,500 |
| Internal tool for 20 users | ~2,000 | ~200 | ~5,000 |
| Free limit | 100,000/day | 1,000/day | 1,000,000/month |
Most side projects and small internal tools won't reach 10% of the free tier limits. You only need to think about Workers Paid ($5/month) once you scale to thousands of daily active users with heavy write patterns.
When you'll hit the free tier:
- KV writes: apps with frequent writes — every config update, every new session is 1 write. Apps with 1,000+ new active sessions per day will approach the limit
- Queue: 1 million operations/month ≈ 33,000 messages/day — needs significant traffic to reach
Workers KV — global key-value store
What KV is
Workers KV is a distributed key-value store running across Cloudflare's entire edge network. Each key-value pair is replicated to multiple locations — reads from any edge are fast.
KV is not a database. No queries, no indexes, no relations. Just get, put, delete, and list.
Free tier
| Workers Free | Workers Paid | |
|---|---|---|
| Reads/day | 100,000 | 10 million/month |
| Writes/day | 1,000 | 1 million/month |
| Storage | 1GB | 1GB ($0.50/GB beyond) |
| Price | Free | $5/month |
Create a KV namespace
wrangler kv namespace create MY_KV
# Output: id = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
# Separate namespace for local dev
wrangler kv namespace create MY_KV --preview
Add to wrangler.toml:
[[kv_namespaces]]
binding = "MY_KV"
id = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
preview_id = "yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy"
Using KV in Workers and Pages
In a Worker:
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const { pathname } = new URL(request.url);
// Read a value
if (pathname === "/config") {
const value = await env.MY_KV.get("app_config");
return Response.json({ config: value });
}
// Write a value
if (pathname === "/config" && request.method === "PUT") {
const body = await request.json<{ value: string }>();
await env.MY_KV.put("app_config", body.value);
return Response.json({ ok: true });
}
return new Response("Not found", { status: 404 });
},
};
In a Pages Function (functions/api/config.ts):
export async function onRequestGet(context: EventContext<Env, string, unknown>) {
const value = await context.env.MY_KV.get("app_config");
return Response.json({ config: value });
}
TTL — auto-expire after a set time
// Store with a 1-hour TTL (3600 seconds)
await env.MY_KV.put("session:abc123", JSON.stringify(sessionData), {
expirationTtl: 3600,
});
// Store with an absolute expiration timestamp
await env.MY_KV.put("token:xyz", token, {
expiration: Math.floor(Date.now() / 1000) + 86400, // expires in 24h
});
Storing JSON objects
KV only stores strings — serialize and deserialize manually:
// Write
await env.MY_KV.put(
`user:${userId}`,
JSON.stringify({ name, email, role }),
{ expirationTtl: 3600 }
);
// Read
const raw = await env.MY_KV.get(`user:${userId}`);
const user = raw ? JSON.parse(raw) : null;
Listing keys
const list = await env.MY_KV.list({ prefix: "user:", limit: 100 });
// list.keys = [{ name: "user:1" }, { name: "user:2" }, ...]
// list.list_complete = false if there are more keys to fetch
What KV is good for
Use KV for:
- Feature flags, app config — read often, write rarely
- Session tokens, auth cache
- Simple rate limiting counters (though not 100% accurate due to eventual consistency)
- Caching external API results with TTL
- Static lookup data read frequently (country lists, currencies...)
Don't use KV for:
- Data that needs strong consistency — KV is eventually consistent, reads may return stale values for a few seconds
- Replacing a database — no querying, no sorting
- Precise counters — use Durable Objects instead
Real-world use cases with Pages
For apps deployed on Cloudflare Pages (Next.js, Astro, SvelteKit...), Pages Functions can read from KV before returning a response — a good fit for data that doesn't change often but needs to be fast at the edge:
A/B testing and feature flags
Store feature on/off config in KV; Pages Function reads it before rendering to decide which variant to return:
// functions/api/config.ts
export async function onRequestGet(context: EventContext<Env, string, unknown>) {
const flags = await context.env.MY_KV.get("feature_flags");
const { newCheckout, betaDashboard } = JSON.parse(flags ?? "{}");
return Response.json({ newCheckout, betaDashboard });
}
Caching CMS or external API results
Pages Function calls Contentful, Notion, or any quota-limited API — store the result in KV with TTL instead of calling the API on every request:
export async function onRequestGet(context: EventContext<Env, string, unknown>) {
const cacheKey = "homepage_posts";
const cached = await context.env.MY_KV.get(cacheKey);
if (cached) return Response.json(JSON.parse(cached));
const posts = await fetchFromCMS(); // call external API
await context.env.MY_KV.put(cacheKey, JSON.stringify(posts), {
expirationTtl: 300, // cache for 5 minutes
});
return Response.json(posts);
}
Dynamic redirects
Store a slug → URL table in KV; Pages Function reads and redirects — no redeployment needed when adding new redirects:
// functions/r/[slug].ts
export async function onRequestGet(context: EventContext<Env, "slug", unknown>) {
const { slug } = context.params;
const url = await context.env.MY_KV.get(`redirect:${slug}`);
if (url) return Response.redirect(url, 301);
return new Response("Not found", { status: 404 });
}
Adding a new redirect only requires:
wrangler kv key put --binding MY_KV "redirect:promo-sale" "https://yoursite.com/sale"
Rate limiting form submissions
Count submissions per IP in KV; Pages Function checks before processing:
export async function onRequestPost(context: EventContext<Env, string, unknown>) {
const ip = context.request.headers.get("CF-Connecting-IP") ?? "unknown";
const key = `ratelimit:contact:${ip}`;
const count = parseInt((await context.env.MY_KV.get(key)) ?? "0");
if (count >= 5) {
return Response.json({ error: "Too many requests" }, { status: 429 });
}
await context.env.MY_KV.put(key, String(count + 1), { expirationTtl: 3600 });
// process form...
return Response.json({ ok: true });
}
KV in Pages works best for data that's read frequently but updated infrequently — not as primary storage.
KV is not publicly accessible
Like D1, KV has no public REST API or connection string. To read/write KV from outside the Cloudflare platform:
# Read a key via Wrangler CLI
wrangler kv key get --binding MY_KV "app_config"
# Write a key
wrangler kv key put --binding MY_KV "app_config" "production"
# List all keys
wrangler kv key list --binding MY_KV --prefix "user:"
Cloudflare Queues — async job processing
What Queues are and why you need them
In serverless, Workers have a limited execution time (30 seconds on the Free plan, 15 minutes on Paid). For tasks that take longer or don't need to block the response — sending emails, resizing images, calling webhooks, syncing data — handling them directly in the request handler is the wrong approach.
Queues solve this by separating the producer from the consumer:
Request → Worker (producer) → Queue → Worker (consumer) → processes job
↓
returns response immediately
(doesn't wait for the job to finish)
Free tier
| Workers Free | Workers Paid | |
|---|---|---|
| Operations/month | 1 million | First 1M free, $0.40/M after |
| Message size | 128KB | 128KB |
| Retention | 4 days | 4 days |
| Price | Free | $5/month (Workers Paid) |
Create a Queue
wrangler queues create my-queue
Configure in wrangler.toml:
# Producer binding — this Worker sends messages to the queue
[[queues.producers]]
binding = "MY_QUEUE"
queue = "my-queue"
# Consumer binding — this Worker receives and processes messages
[[queues.consumers]]
queue = "my-queue"
max_batch_size = 10 # process up to 10 messages per invocation
max_batch_timeout = 5 # or wait up to 5 seconds if batch isn't full
max_retries = 3 # retry up to 3 times on failure
dead_letter_queue = "my-queue-dlq" # queue for messages that exhaust retries
Sending messages (Producer)
In a Worker or Pages Function:
// Send a single message
await env.MY_QUEUE.send({
type: "send_email",
to: "[email protected]",
subject: "Welcome",
templateId: "welcome",
});
// Send multiple messages at once
await env.MY_QUEUE.sendBatch([
{ body: { type: "resize_image", imageId: "img_1" } },
{ body: { type: "resize_image", imageId: "img_2" } },
{ body: { type: "resize_image", imageId: "img_3" } },
]);
After send() completes, the Worker returns a response immediately — it doesn't wait for the job to be processed.
Processing messages (Consumer)
The consumer Worker exports a queue handler:
export default {
// Handler for normal HTTP requests
async fetch(request: Request, env: Env): Promise<Response> {
return new Response("OK");
},
// Handler for Queue messages
async queue(batch: MessageBatch<JobMessage>, env: Env): Promise<void> {
for (const message of batch.messages) {
const job = message.body;
try {
if (job.type === "send_email") {
await sendEmail(job.to, job.subject, job.templateId);
message.ack(); // confirm successful processing
} else if (job.type === "resize_image") {
await resizeAndUpload(job.imageId, env);
message.ack();
} else {
message.retry(); // return to queue for retry
}
} catch (err) {
console.error("Job failed:", err);
message.retry(); // retry on error
}
}
},
};
Ack and Retry
message.ack(): tells Cloudflare the message was processed successfully — removes it from the queuemessage.retry(): returns the message to the queue for another attempt- If
ack()is not called before the handler exits, Cloudflare automatically retries - After
max_retriesfailures, the message is moved to the dead letter queue
Dead Letter Queue
The DLQ holds messages that couldn't be processed after all retry attempts. Create a separate queue to monitor them:
wrangler queues create my-queue-dlq
[[queues.consumers]]
queue = "my-queue-dlq"
max_batch_size = 1
// DLQ consumer — log or alert on dead letters
async queue(batch: MessageBatch, env: Env): Promise<void> {
for (const message of batch.messages) {
console.error("Dead letter:", JSON.stringify(message.body));
// send a Slack alert, email, or store in D1 for investigation
message.ack();
}
}
Real-world use case with Pages
With a Next.js or Astro app deployed on Cloudflare Pages, Pages Functions can send messages to a Queue:
// functions/api/register.ts
export async function onRequestPost(context: EventContext<Env, string, unknown>) {
const { email, name } = await context.request.json();
// Save user to D1
await context.env.DB.prepare(
"INSERT INTO users (email, name) VALUES (?, ?)"
).bind(email, name).run();
// Queue a welcome email job — doesn't block the response
await context.env.MY_QUEUE.send({
type: "welcome_email",
to: email,
name,
});
return Response.json({ ok: true }); // returns immediately, email sends in background
}
Queues are not publicly accessible
Queues have no HTTP endpoint to send messages from outside. Messages can only be sent via binding inside Workers or Pages Functions.
If you need to trigger a job from an external server, create a Worker endpoint that receives requests and forwards them to the Queue:
// Worker acting as a gateway for external requests
export default {
async fetch(request: Request, env: Env): Promise<Response> {
// Authenticate the request (secret header, JWT...)
const auth = request.headers.get("X-Internal-Secret");
if (auth !== env.INTERNAL_SECRET) {
return new Response("Unauthorized", { status: 401 });
}
const job = await request.json();
await env.MY_QUEUE.send(job);
return Response.json({ queued: true });
},
};
KV vs Queue vs D1 — which to use
| Use case | Use |
|---|---|
| Config, feature flags, cache | KV |
| Short-lived sessions, auth tokens | KV with TTL |
| Relational data, needs querying | D1 |
| Sending emails, processing images async | Queue |
| Webhook sync, retry logic | Queue |
| Precise counters, distributed locks | Durable Objects |
| Files, images, video | R2 |