Rate limiting with Cloudflare KV
By Flavio Copes
Rate limit Cloudflare Workers API routes with KV fixed-window counters and expirationTtl cleanup. Soft limits that work with KV eventual consistency.
You expose a public API route. Someone writes a bot loop. Your LLM bill explodes.
Rate limiting fixes that. On Cloudflare Workers, KV is the simplest store for it. No Durable Objects, no Redis, no extra service.
I added this to StackPlan for the free LLM tools — five roasts per day per visitor, keyed by anon token plus IP.
This is abuse protection, not a billing-grade quota. KV is eventually consistent. Two requests hitting at the same millisecond might both read count: 0. That’s fine for soft limits.
Fixed-window counters
The idea: bucket requests into time windows. Count how many hit each bucket. Reject when the count exceeds your limit.
A window key looks like:
rl:roast:anon_abc:1.2.3.4:293120
That’s rl: + route name + caller identity + window number. The window number is Math.floor(now / windowSeconds).
When the window rolls over, you get a fresh key automatically. Old keys expire on their own (more on that below).
The implementation
async function checkRateLimit(kv, opts) {
const window = Math.floor(Date.now() / 1000 / opts.windowSeconds)
const kvKey = `rl:${opts.name}:${opts.key}:${window}`
const raw = await kv.get(kvKey)
const count = raw ? parseInt(raw, 10) || 0 : 0
if (count >= opts.limit) {
return { allowed: false, remaining: 0, limit: opts.limit }
}
await kv.put(kvKey, String(count + 1), {
expirationTtl: Math.max(60, opts.windowSeconds),
})
return { allowed: true, remaining: opts.limit - count - 1, limit: opts.limit }
}
Read the counter. If it’s at the limit, return allowed: false. Otherwise increment and allow.
The increment is not atomic. Two concurrent requests can both read 0 and both write 1. You might allow six requests when the limit is five. For bot protection, that’s acceptable.
Caller identity
Rate limit per IP alone and you punish everyone behind a NAT. Rate limit per cookie alone and bots rotate cookies.
Combine both:
function rateLimitKey(anonToken, request) {
const ip =
request.headers.get('CF-Connecting-IP') ??
request.headers.get('X-Forwarded-For')?.split(',')[0]?.trim() ??
'unknown'
return `${anonToken}:${ip}`
}
On Cloudflare, CF-Connecting-IP is the real client IP. Use it when you have it.
Automatic cleanup with expirationTtl
Every kv.put sets expirationTtl. KV deletes the key after that many seconds.
Set it to at least your window length. I use Math.max(60, windowSeconds) so even short windows get a one-minute floor.
You never need a cron job to sweep old counters. They vanish on their own.
Why eventual consistency is fine
KV reads can be stale for a second or two across the globe. A counter might briefly undercount.
For a hard billing meter, that’s a problem. For “stop someone from hammering my roast endpoint,” it’s not. You’re blocking sustained abuse, not counting pennies.
Fail open on KV errors
If KV is down or your binding is misconfigured, don’t block legitimate users.
Wrap the check in a try/catch and allow the request when KV throws:
async function safeRateLimit(kv, opts) {
try {
return await checkRateLimit(kv, opts)
} catch (err) {
console.error('[rate-limit] KV error, allowing request:', err)
return { allowed: true, remaining: opts.limit, limit: opts.limit }
}
}
A brief KV outage is better than a site that returns 500 on every POST.
Using it in a route
const rate = await safeRateLimit(env.CACHE, {
name: 'roast',
key: rateLimitKey(anonToken, request),
limit: 5,
windowSeconds: 86400, // one day
})
if (!rate.allowed) {
return new Response('Too many requests', { status: 429 })
}
Bind KV in wrangler.jsonc as CACHE (or whatever name you like). Pass env.CACHE into the helper.
When to reach for something else
Need a precise, atomic counter? Use a Durable Object with in-memory state.
Need rate limits across many Workers in real time? Durable Objects or an external store.
Need “stop casual abuse on a free tool”? KV fixed windows are enough. Cheap, simple, good enough.
Related posts about cloudflare: