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 instance
const 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 CaseKey FormatExample
API endpointapi:{endpoint}:{userId}api:upload:user_123
Auth actionauth:{action}:{identifier}auth:login:user@example.com
Featurefeature:{name}:{userId}feature:export:user_123

Configuration

Environment Variables

VariableDescriptionDefault
BETTER_AUTH_RATE_LIMIT_STORAGEStorage backend: database, memory, secondary-storagedatabase
Use CaseMaxWindow
Auth endpoints515 minutes
API endpoints1001 minute
File uploads101 minute
Expensive operations51 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 = $now
WHERE (last_request + $windowMs) <= $now OR count < $max
RETURNING 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:

// Good
const rateLimitService = createRateLimitService();
export async function handler() { ... }
// Bad
export 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

StorageLatencyCoordinationUse Case
Database~5-10msCross-instanceDefault, most deployments
Redis~1-2msCross-instanceHigh-traffic APIs
Memory<1msInstance-localServerless, 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 hour
await 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?
Database storage is sufficient for most SaaS applications. It adds about 5-10ms latency per check. Switch to Redis only if rate limiting adds measurable latency to critical paths or your database is already under heavy load.
How does the sliding window algorithm work?
The service tracks the count of requests and the timestamp of the last request. When a new request arrives, it checks if the time since last request exceeds the window. If so, the count resets to 1. Otherwise, it increments the count and checks against the limit.
Can I use different limits for different endpoints?
Yes, use different keys and options per endpoint. For example, rateLimitService.limit('api:upload:' + userId, { max: 10, windowSeconds: 60 }) for uploads and a different configuration for other endpoints.
How do I handle rate limits in API responses?
Return 429 status with standard rate limit headers: X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, and Retry-After. The decision object from limit() provides all values needed for these headers.

Next: Testing Utilities