# Rate limiting with Cloudflare KV

> Rate limit Cloudflare Workers API routes with KV fixed-window counters and expirationTtl cleanup. Soft limits that work with KV eventual consistency.

Author: Flavio Copes | Published: 2026-07-12 | Canonical: https://flaviocopes.com/rate-limiting-cloudflare-kv/

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](https://stackplan.dev) 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

```js
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:

```js
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:

```js
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

```js
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.
