# Rate Limit Service

> Database-backed rate limiting with pluggable storage backends for the TanStack Start Drizzle SaaS Kit.

*Canonical: https://makerkit.dev/docs/tanstack-drizzle/database/rate-limit-service*

---

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

```typescript
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

```typescript
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.

```typescript
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:

```typescript
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:

```sql
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

```sql
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:

```typescript
// 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:

```typescript
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:

```typescript
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:

```typescript
// Delete entries older than 1 hour
await db
  .delete(rateLimit)
  .where(lt(rateLimit.lastRequest, Date.now() - 3600000));
```

Run periodically via cron or scheduled job.

{% faq
   title="Frequently Asked Questions"
   items=[
     {"question": "Should I use database or Redis for rate limiting?", "answer": "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."},
     {"question": "How does the sliding window algorithm work?", "answer": "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."},
     {"question": "Can I use different limits for different endpoints?", "answer": "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."},
     {"question": "How do I handle rate limits in API responses?", "answer": "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](./testing)
