Rate Limiting

Rate Limiting

Warpkit includes an IP-based, in-memory rate limiter for server functions and API routes. It is disabled outside production: dev and test run unthrottled.

Usage in a server function

import { ERROR_CODES } from '@/lib/constants';
import { createRateLimiter } from '@/server/rate-limit';

const limiter = createRateLimiter({ windowMs: 60_000, max: 20 });

export const myFn = createServerFn({ method: 'POST' }).handler(async () => {
  if (limiter.check())
    return err(ERROR_CODES.RATE_LIMITED, 'Too many requests');
  // ...
});

Create one limiter per endpoint: each has its own store.

Usage in a server route

import { getClientIP } from '@/server/rate-limit';

const ip = getClientIP({
  'cf-connecting-ip': request.headers.get('cf-connecting-ip') ?? undefined,
  'x-real-ip': request.headers.get('x-real-ip') ?? undefined,
  'x-forwarded-for': request.headers.get('x-forwarded-for') ?? undefined,
});

if (limiter.check(ip ?? undefined)) {
  return Response.json({ error: 'Too many requests' }, { status: 429 });
}

IP detection: TRUST_PROXY

getClientIP does NOT walk a priority chain. It reads exactly ONE header, selected by the TRUST_PROXY env var, because trusting a header your proxy does not actually set lets clients spoof their IP and bypass rate limits:

TRUST_PROXYHeader trusted
cloudflare (default)cf-connecting-ip
nginxx-real-ip
proxyx-forwarded-for (first IP)
noneno header , every request keys to 'unknown'

Set it to match your actual deployment topology (see .env.example). Falls back to 'unknown' when the selected header is absent; the limiter is disabled entirely outside production.

Config options

OptionTypeDescription
windowMsnumberWindow duration in milliseconds
maxnumberMax requests allowed per window

Memory management

The store auto-cleans expired entries every 5 minutes. There is no persistent storage: limits reset on server restart.


Per-user DB-backed rate limiting

For authenticated mutations, use checkUserRateLimit from @/lib/db/user-rate-limiter. This counts user_events rows for a given event type within a sliding window and is backed by per-user SQLite, so limits survive server restarts.

import { checkUserRateLimit } from '@/lib/db/user-rate-limiter';
import { logUserEvent } from '@/lib/db/user-events';
import { getUserDb, withWriteLock } from '@/lib/db/user-db';

export const createItem = createServerFn({ method: 'POST' }).handler(async () => {
  const user = await requireUser();
  if (!user) return err(ERROR_CODES.UNAUTHORIZED, 'Not authenticated');

  return withWriteLock(user.id, () => {
    const db = getUserDb(user.id);
    if (checkUserRateLimit(db, 'item.created', { windowMs: 60_000, max: 20 }))
      return err(ERROR_CODES.RATE_LIMITED, 'Too many requests');

    // ... write logic ...
    logUserEvent(db, 'item.created');
    return ok({ id });
  });
});

Both checkUserRateLimit and logUserEvent must be called inside withWriteLock. checkUserRateLimit reads event count; logUserEvent writes the event that feeds future checks. Both are no-ops outside NODE_ENV=production.

When to use which limiter:

ScenarioLimiter
Unauthenticated endpoint (public API, lead capture)createRateLimiter (IP-based)
Authenticated mutation by signed-in usercheckUserRateLimit (per-user DB)
Both (defense in depth)Use both: IP gate before lock, user gate inside lock

See also: Gate ordering for correct placement of rate-limit checks relative to withWriteLock.