Rate Limit Service
Database-backed rate limiting with pluggable storage backends
Database-backed rate limiting service with pluggable storage backends.
Overview
The rate limit service provides configurable rate limiting using a sliding window algorithm. It supports multiple storage backends:
- Database (default) - Persistent, works across instances
- Secondary Storage - Redis/KV stores with TTL support
- Memory - Fast but instance-local (via Better Auth)
Usage
Basic Usage
import { createRateLimitService } from '@kit/database/rate-limit';const rateLimitService = createRateLimitService();const result = await rateLimitService.limit('upload:image:user123', { windowSeconds: 60, max: 10,});if (!result.success) { return new Response('Rate limited', { status: 429, headers: { 'X-Retry-After': String(result.retryAfter) }, });}Response Shape
interface RateLimitDecision { success: boolean; // Whether request is allowed remaining: number; // Requests remaining in window limit: number; // Max requests per window resetAt: number; // Timestamp when window resets retryAfter: number | null; // Seconds until retry (if blocked) key: string; // The rate limit key}Storage Backends
Database (Default)
Uses PostgreSQL via Drizzle. Schema auto-generated by Better Auth.
const service = createRateLimitService(); // Uses default db// orconst service = createRateLimitService({ database: customDb });Secondary Storage (Redis/KV)
For external stores like Redis, Upstash, or Vercel KV:
import { createRateLimitService, createSecondaryRateLimitStorageFactory,} from '@kit/database/rate-limit';const storage = createSecondaryRateLimitStorageFactory({ get: (key) => redis.get(key), set: (key, value, ttlSeconds) => redis.set(key, value, { ex: ttlSeconds }),});const service = createRateLimitService({ storage });Key Naming Conventions
Use namespaced keys to separate rate limits:
| Use Case | Key Format | Example |
|---|---|---|
| API endpoint | api:{endpoint}:{userId} | api:upload:user_123 |
| Auth action | auth:{action}:{identifier} | auth:login:user@example.com |
| Feature | feature:{name}:{userId} | feature:export:user_123 |
Configuration
Environment Variables
| Variable | Description | Default |
|---|---|---|
BETTER_AUTH_RATE_LIMIT_STORAGE | Storage backend: database, memory, secondary-storage | database |
UPLOAD_RATE_LIMIT_MAX | Max uploads per window | 10 |
UPLOAD_RATE_LIMIT_WINDOW | Window in seconds | 60 |
Implementation Details
Atomic Operations
The database service uses a single upsert query:
INSERT INTO rate_limit (id, key, count, last_request)VALUES ($id, $key, 1, $now)ON CONFLICT (id) DO UPDATE SET count = CASE WHEN last_request + $windowMs < $now THEN 1 ELSE count + 1 END, last_request = $nowWHERE (last_request + $windowMs) <= $now OR count < $maxRETURNING count, last_request;This ensures:
- No race conditions
- Single database round-trip
- Automatic window reset
Schema
CREATE TABLE "rate_limit" ( "id" text PRIMARY KEY NOT NULL, "key" text, "count" integer, "last_request" bigint);The id column IS the rate limit key (e.g., upload:image:user123).
Best Practices
Create singleton at module level - Avoid creating service per request
// Goodconst rateLimitService = createRateLimitService();export async function handler() { ... }// Badexport async function handler() {const service = createRateLimitService(); // New instance per request}Rate limit after auth - Don't consume rate limit for unauthenticated requests
const session = await auth.api.getSession({ headers });if (!session) return unauthorized();const rateLimit = await rateLimitService.limit(`api:${session.user.id}`);Include standard headers in 429 responses
return new Response('Rate limited', {status: 429,headers: {'X-Retry-After': String(result.retryAfter),'X-RateLimit-Limit': String(result.limit),'X-RateLimit-Remaining': String(result.remaining),'X-RateLimit-Reset': String(result.resetAt),},});Use appropriate windows based on use case:
- Auth endpoints: 5 attempts / 15 minutes
- API endpoints: 100 requests / minute
- File uploads: 10 / minute
- Expensive operations: 5 / hour
Performance Considerations
| Storage | Latency | Coordination | Use Case |
|---|---|---|---|
| Database | ~5-10ms | Cross-instance | Default, most deployments |
| Redis | ~1-2ms | Cross-instance | High-traffic APIs |
| Memory | <1ms | Instance-local | Serverless, single instance |
For most SaaS applications, database storage is sufficient. Consider Redis only if:
- Rate limiting adds measurable latency
- You need sub-millisecond response times
- Database is already under heavy load
Cleanup
The database table grows over time. Better Auth handles cleanup, but for custom implementations:
// Delete entries older than 1 hourawait db .delete(rateLimit) .where(lt(rateLimit.lastRequest, Date.now() - 3600000));Run periodically via cron or scheduled job.