Rate Limit Service
Database-backed rate limiting with pluggable storage backends for the Next.js Drizzle SaaS Kit.
The rate limit service provides configurable rate limiting using a sliding window algorithm. It uses the database by default but supports pluggable storage backends for high-traffic scenarios.
Overview
The rate limit service supports multiple storage backends:
- Database (default) - Persistent, works across instances, adds ~5-10ms latency
- Secondary Storage - Redis/KV stores with TTL support for high-traffic APIs
- Memory - Fast but instance-local (via Better Auth configuration)
For most SaaS applications, database storage is sufficient.
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: { '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 (ms) retryAfter: number | null; // Seconds until retry (if blocked) key: string; // The rate limit key}Storage Backends
Database (Default)
Uses PostgreSQL via Drizzle. The schema is auto-generated by Better Auth.
const service = createRateLimitService(); // Uses default db// Or with a custom database instanceconst 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 by feature and user:
| 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 |
Recommended Limits
| Use Case | Max | Window |
|---|---|---|
| Auth endpoints | 5 | 15 minutes |
| API endpoints | 100 | 1 minute |
| File uploads | 10 | 1 minute |
| Expensive operations | 5 | 1 hour |
Implementation Details
Atomic Operations
The database service uses a single upsert query to avoid race conditions:
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 between concurrent requests
- Single database round-trip
- Automatic window reset when time elapsed
Schema
CREATE TABLE "rate_limit" ( "id" text PRIMARY KEY NOT NULL, "key" text, "count" integer, "last_request" bigint);The id column equals the rate limit key (e.g., upload:image:user123).
Best Practices
Create singleton at module level to avoid creating the 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 to avoid consuming limits 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 for proper client handling:
return new Response('Rate limited', { status: 429, headers: { 'Retry-After': String(result.retryAfter), 'X-RateLimit-Limit': String(result.limit), 'X-RateLimit-Remaining': String(result.remaining), 'X-RateLimit-Reset': String(result.resetAt), },});Common Mistakes to Avoid
Using IP-based rate limiting for authenticated endpoints: Use the user ID instead. IP-based limits fail for shared IPs (offices, VPNs) and don't work well with mobile clients.
Forgetting to handle the rate limit response: Always check result.success before proceeding. Ignoring the result defeats the purpose of rate limiting.
Creating new service instances per request: This works but wastes resources. Create the service once at module level.
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 |
Consider Redis only if:
- Rate limiting adds measurable latency to critical paths
- 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.
Frequently Asked Questions
Should I use database or Redis for rate limiting?
How does the sliding window algorithm work?
Can I use different limits for different endpoints?
How do I handle rate limits in API responses?
Next: Testing Utilities