CapyDB/ docs
GuidesK/V

Rate limiting

Sliding-window, fixed-window and token-bucket rate limiting against a CapyDB K/V store, using @upstash/ratelimit at its default settings.

Rate limiting is the reason most people reach for a key-value store, so it is worth stating plainly: CapyDB K/V runs @upstash/ratelimit unmodified, at its default settings. There is no CapyDB adapter and no configuration flag.

The whole integration

app/api/send/route.ts
import { Ratelimit } from '@upstash/ratelimit'
import { Redis } from '@upstash/redis'

const ratelimit = new Ratelimit({
  redis: new Redis({
    url: process.env.CAPYDB_KV_REST_URL!,
    token: process.env.CAPYDB_KV_REST_TOKEN!,
  }),
  limiter: Ratelimit.slidingWindow(10, '10 s'),
  analytics: true,
  prefix: 'ratelimit',
})

export async function POST(request: Request) {
  const identifier = request.headers.get('x-forwarded-for') ?? 'anonymous'
  const { success, limit, remaining, reset } = await ratelimit.limit(identifier)

  if (!success) {
    return new Response('Too many requests', {
      status: 429,
      headers: {
        'RateLimit-Limit': String(limit),
        'RateLimit-Remaining': String(remaining),
        'RateLimit-Reset': String(Math.ceil((reset - Date.now()) / 1000)),
      },
    })
  }

  // ...
}

Redis.fromEnv() reads UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN. CapyDB publishes CAPYDB_KV_REST_URL and CAPYDB_KV_REST_TOKEN, so construct the client explicitly as above.

Choosing an algorithm

LimiterBehaviourCost per check
Ratelimit.fixedWindow(n, '10 s')Counts per fixed window; allows a burst of 2n across a window boundary1 command
Ratelimit.slidingWindow(n, '10 s')Weights the previous window, so the boundary burst disappears2 commands
Ratelimit.tokenBucket(refill, '10 s', capacity)Allows a burst up to capacity, then refills steadily1 script

slidingWindow is the right default: it costs one extra command and removes the burst that makes fixedWindow surprising in production. Reach for tokenBucket when a burst is a feature - a client that batches, then goes quiet.

Where the limiter runs matters

A rate-limit check is a blocking round trip in front of work the user is waiting for. Its latency is added to every request, including the ones you allow.

A store is placed in the same region as its project, so if your application runs in that region the check is a same-datacenter round trip. If your application runs on the other side of the world from the store, the check costs that round trip - and a limiter that adds 100 ms to every request is worse than the abuse it prevents. Put the store, the application and the database in one region, or use ephemeralCache (below) to absorb the repeats.

const ratelimit = new Ratelimit({
  redis,
  limiter: Ratelimit.slidingWindow(10, '10 s'),
  // Denies within the current process without a round trip, once an identifier
  // is already known to be over its limit. Per-instance, so it is an
  // optimisation, not the limit itself.
  ephemeralCache: new Map(),
})

Failing open, deliberately

If the store is unreachable, limit() rejects. Decide which way that should fail, and write it down

  • the default of letting the exception escape means an unreachable limiter takes your endpoint down with it:
let allowed = true
try {
  allowed = (await ratelimit.limit(identifier)).success
} catch {
  // Fail open: a limiter outage degrades protection, it does not deny service.
  // Invert this for endpoints where an unmetered request is worse than an error.
  allowed = true
}

Multiple limits

Separate limiters need separate prefixes, or they share a keyspace and count each other's requests:

const perIp = new Ratelimit({ redis, limiter: Ratelimit.slidingWindow(100, '60 s'), prefix: 'ip' })
const perUser = new Ratelimit({ redis, limiter: Ratelimit.slidingWindow(20, '60 s'), prefix: 'user' })

Capacity

Rate-limit keyspaces are small: a counter and a TTL per identifier. The entry plan's 32 MB holds a large number of live identifiers, and expired ones are reclaimed.

Every key @upstash/ratelimit writes carries a TTL, which is exactly what the store's volatile-lru eviction policy needs: if a store does fill, the least recently used counters are discarded and writes keep succeeding. For a limiter that means the oldest identifiers get a fresh allowance, not that the endpoint breaks. Sharing the store with cache entries that have no TTL is what would break it - those cannot be evicted. See Capacity.