Rate limiting

Redis-backed sliding-window rate limiting.

import { createRateLimiter, slidingWindow } from '@kreogen/rate-limit';

const limiter = createRateLimiter({
  limiter: slidingWindow(10, '10 s'),
  prefix: 'contact-form',
});

const { success, remaining, reset } = await limiter.limit(identifier);

if (!success) {
  throw new Error('Too many requests. Try again shortly.');
}

Windows accept ms, s, m, h and d'30 s', '1h', '1d'.

How it works

Each identifier gets a Redis sorted set of request timestamps. On every call a single Lua script trims entries older than the window, counts what remains, and inserts the new one only if the count is under the limit.

Doing all three in one script matters: split into separate commands, two concurrent requests can both read a count below the limit and both be allowed.

It fails open

When REDIS_URL is unset — or Redis is unreachable — requests are allowed.

That is deliberate. A limiter exists to protect a feature, and taking the feature down when the limiter itself is unavailable inverts the tradeoff. If a particular route needs the opposite behaviour, check redis directly and decide for yourself:

import { redis } from '@kreogen/rate-limit';

if (!redis) {
  throw new Error('Rate limiting is required for this endpoint.');
}

Configuration

VariableRequiredPurpose
REDIS_URLnoredis:// or rediss:// URL

Redis ships in docker-compose.yml, so locally this needs no setup beyond bun run docker:up. In production point it at whatever Redis the project uses — managed or self-hosted, both work, because this uses the standard protocol rather than a vendor REST API.

Choosing an identifier

Prefer the authenticated user. Fall back to clientIdentifier, never to the raw header:

import { clientIdentifier } from '@kreogen/rate-limit';
import { auth } from '@kreogen/auth/session';
import { headers } from 'next/headers';

const { userId } = await auth();
const identifier = userId ?? clientIdentifier(await headers());

headers().get('x-forwarded-for') is not an identifier. XFF is a list each proxy appends to, so the leftmost entry is whatever the original client sent — attacker-controlled and free-form. A limiter keyed on it is not a limiter: the caller sends a fresh random value on every request, lands in a fresh bucket every time, and never hits the limit.

clientIdentifier counts from the right instead, since only the entries appended by infrastructure you control are trustworthy. It defaults to one proxy, which is the shape of every single-load-balancer deployment:

clientIdentifier(requestHeaders, { trustedProxyCount: 2 });

Match that number to the deployment. Too high and the index walks back into the attacker-controlled prefix, reintroducing exactly the spoof it exists to prevent. Too low and every caller behind the outermost proxy shares one bucket.

Even correct, IP is a blunt instrument: users behind shared egress share a bucket. Use it for unauthenticated surfaces and the user id everywhere else.

Redis connection behaviour

Two settings are load-bearing for the fail-open promise, and both are the opposite of ioredis' defaults:

  • enableOfflineQueue: false. On, commands issued while the socket is down are buffered and replayed on reconnect — so a request that should have been answered in milliseconds hangs for as long as the outage lasts. Off, it rejects immediately and limit() falls through to allowing the request.
  • lazyConnect: true. Importing the package opens no socket, so a build step or a test never connects.

An error listener is attached for the same reason. ioredis emits error on every failed connection attempt, and Node throws on an unhandled one — so a Redis that is merely slow to start would take the whole process down, which is the precise opposite of failing open.