Rate Limiting

Production-ready rate limiting using Bun’s native Redis client with sliding window algorithm.

Overview

┌──────────────────────────────────────────────────────────────┐
│                    Rate Limit Flow                           │
│                                                              │
│  Request ──▶ Extract ID ──▶ Check Redis ──▶ Allow/Deny       │
│                 (user/IP)    Window Counter                  │
│                                                              │
│  If exceeded:                                                │
│    ← 429 Too Many Requests + Retry-After header              │
└──────────────────────────────────────────────────────────────┘

Response Headers

Every response includes rate limit info:
HeaderDescription
X-RateLimit-LimitMax requests per window
X-RateLimit-RemainingRequests left in current window
X-RateLimit-ResetUnix timestamp when window resets

Rate Limited Response (429)

{
  "error": {
    "code": "RATE_LIMIT_EXCEEDED",
    "message": "Too many requests. Please retry after 45 seconds.",
    "retryAfter": 45
  }
}
With headers:
HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1704124800
Retry-After: 45

How It Works

Sliding Window Algorithm

┌─────────────────────────────────────────────────────┐
│  Window: 60 seconds                                 │
│                                                     │
│  Redis Key: ratelimit:user:usr_123                  │
│                                                     │
│  Operations:                                        │
│  1. ZREMRANGEBYSCORE key 0 (now - 60)               │
│  2. ZCARD key                                       │
│  3. ZADD key (now) score (now)                      │
│  4. EXPIRE key 60                                   │
│                                                     │
│  If count > limit ──▶ DENY (429)                    │
└─────────────────────────────────────────────────────┘

Identifier Selection

function getIdentifier(c: Context): string {
  // 1. Authenticated user
  if (c.get('userId')) {
    return `user:${c.get('userId')}`;
  }

  // 2. API key
  const apiKey = c.req.header('X-API-Key');
  if (apiKey) {
    return `apikey:${hash(apiKey)}`;
  }

  // 3. IP address (with proxy support)
  const ip = c.req.header('X-Forwarded-For')
             ?? c.req.header('X-Real-IP')
             ?? c.env.IP;
  return `ip:${ip}`;
}

Configuration

RATE_LIMIT_ENABLED=true
RATE_LIMIT_TTL=60
RATE_LIMIT_MAX=100

Environment Examples

EnvTTLMaxUse Case
Development601000High limit, no impact
Staging60200Moderate testing
Production60100Standard public API
Strict6020Sensitive endpoints

Endpoint-Specific Limits

Override limits per endpoint in route config:
// Standard endpoints
app.post('/api/v1/users', rateLimitMiddleware(), createUser);

// Strict endpoints (login, password reset)
app.post('/api/v1/auth/login',
  rateLimitMiddleware({ max: 5, window: 60 }),
  login
);

// Password reset (very strict)
app.post('/api/v1/auth/password-reset',
  rateLimitMiddleware({ max: 3, window: 3600 }),
  passwordReset
);

Skip Rate Limiting

Internal service calls can skip rate limiting:
// Internal request header
app.use('/api/v1/internal/*',
  skipRateLimitMiddleware(),
  internalHandler
);

Redis Key Schema

ratelimit:<type>:<identifier>  TTL: <window>
TypeExample KeyTTL
Userratelimit:user:usr_abc12360s
IPratelimit:ip:192.168.1.160s
API Keyratelimit:apikey:hash_xyz60s

Performance

  • Redis O(log N) for sorted set operations
  • < 1ms typical latency
  • Atomic operations (no race conditions)
  • No external calls if Redis unavailable (fail-open by default)

Monitoring

Log Rate Limit Events

// Middleware logs when limit exceeded
logger.warn('Rate limit exceeded', {
  identifier: id,
  count: currentCount,
  limit: maxRequests,
  window: ttl,
});

Metrics to Track

  1. rate_limit_exceeded_total — Counter of denials
  2. rate_limit_remaining — Gauge of remaining requests
  3. rate_limit_reset_seconds — Time until window reset

Security Considerations

ConcernMitigation
IP spoofingTrust X-Forwarded-For from known proxies only
API key sharingRate limit by user after auth
DoS attackFail-open (allow) vs fail-closed (deny)
CostRedis memory: ~1KB per identifier per window

Best Practices

  1. Set generous limits — Users shouldn’t hit limits during normal use
  2. Communicate limits — Document in API docs
  3. Include headers — Let clients track their usage
  4. Fail-open — Allow requests if Redis unavailable
  5. Monitor — Track exceeded events in production