Rate Limiting Patterns: Protecting APIs Without Frustrating Users
Rate limiting patterns: Good rate limiting protects your system while being invisible to legitimate users. Learn the algorithms, implementation patterns, and...
Founder, WRKSHP.DEV
Rate limiting patterns: Good rate limiting protects your system while being invisible to legitimate users. Learn the algorithms, implementation patterns, and... This guide explains Rate Limiting Patterns: Protecting APIs Without Frustrating Users with practical patterns WRKSHP uses on client builds. Use the checklist below to prioritize next steps and avoid common mistakes that waste time and money.
Good rate limiting protects your system while being invisible to legitimate users. Learn the algorithms, implementation patterns, and UX considerations that make rate limiting work. This guide explains Rate Limiting Patterns: Protecting APIs Without Frustrating Users with practical patterns WRKSHP uses on client builds.
Rate limiting patterns overview
Teams implementing rate limiting patterns need clear ownership, observability, and rollout guardrails. The sections below walk through patterns WRKSHP uses on client builds.
Rate limiting is the immune system of your API. Done well, it protects against abuse, ensures fair resource distribution, and keeps your system stable, all while remaining invisible to legitimate users. Done poorly, it frustrates customers, blocks valid traffic, and creates support tickets.
This guide covers the practical patterns for implementing rate limiting that actually works: the algorithms to choose from, where to implement them, and how to communicate limits clearly to API consumers.
Why Rate Limit?
Rate limiting serves multiple purposes:
- Protection: Prevent a single client from overwhelming your system
- Fairness: Ensure resources are distributed among all users
- Cost control: Prevent runaway costs from expensive operations
- Stability: Create predictable system behavior under load
Without rate limiting, a single buggy client can bring down your entire API. A single user running a tight loop can consume resources meant for thousands.
Rate Limiting Algorithms
Fixed Window
The simplest approach: count requests in fixed time windows (e.g., per minute). Reset the count when the window ends.
// Fixed window rate limiter
class FixedWindowRateLimiter {
constructor(limit, windowMs) {
this.limit = limit;
this.windowMs = windowMs;
this.counts = new Map();
}
isAllowed(clientId) {
const now = Date.now();
const windowStart = Math.floor(now / this.windowMs) * this.windowMs;
const key = `${clientId}:${windowStart}`;
// Clean old windows
this.cleanup(windowStart);
const count = this.counts.get(key) || 0;
if (count >= this.limit) {
return false;
}
this.counts.set(key, count + 1);
return true;
}
cleanup(currentWindow) {
for (const key of this.counts.keys()) {
const windowStart = parseInt(key.split(':')[1]);
if (windowStart < currentWindow) {
this.counts.delete(key);
}
}
}
}Pros: Simple to implement and understand.
Cons: Allows burst at window boundaries (100 requests at 11:59:59, 100 more at 12:00:00).
Sliding Window Log
Track timestamps of all requests. Count requests in the sliding window (e.g., last 60 seconds).
class SlidingWindowLogRateLimiter {
constructor(limit, windowMs) {
this.limit = limit;
this.windowMs = windowMs;
this.requests = new Map(); // clientId -> [timestamps]
}
isAllowed(clientId) {
const now = Date.now();
const windowStart = now - this.windowMs;
let timestamps = this.requests.get(clientId) || [];
// Remove old timestamps
timestamps = timestamps.filter(t => t > windowStart);
if (timestamps.length >= this.limit) {
this.requests.set(clientId, timestamps);
return false;
}
timestamps.push(now);
this.requests.set(clientId, timestamps);
return true;
}
}Pros: Accurate, no boundary bursts.
Cons: Memory intensive, stores timestamp for every request.
Sliding Window Counter
Combines fixed window efficiency with sliding window accuracy. Weights the previous window based on overlap.
class SlidingWindowCounterRateLimiter {
constructor(limit, windowMs) {
this.limit = limit;
this.windowMs = windowMs;
this.windows = new Map();
}
isAllowed(clientId) {
const now = Date.now();
const currentWindow = Math.floor(now / this.windowMs);
const previousWindow = currentWindow - 1;
// Weight of previous window (how much of current window has passed)
const elapsedInCurrentWindow = now % this.windowMs;
const previousWeight = 1 - (elapsedInCurrentWindow / this.windowMs);
const currentCount = this.getCount(clientId, currentWindow);
const previousCount = this.getCount(clientId, previousWindow);
const weightedCount = currentCount + (previousCount * previousWeight);
if (weightedCount >= this.limit) {
return false;
}
this.incrementCount(clientId, currentWindow);
return true;
}
getCount(clientId, window) {
return this.windows.get(`${clientId}:${window}`) || 0;
}
incrementCount(clientId, window) {
const key = `${clientId}:${window}`;
this.windows.set(key, (this.windows.get(key) || 0) + 1);
}
}Pros: Memory efficient, smooth rate limiting.
Cons: Slightly more complex, approximate.
Token Bucket
Clients have a bucket that fills with tokens at a steady rate. Each request consumes a token. If the bucket is empty, requests are rejected.
class TokenBucketRateLimiter {
constructor(capacity, refillRate) {
this.capacity = capacity; // Max tokens
this.refillRate = refillRate; // Tokens per second
this.buckets = new Map();
}
isAllowed(clientId, tokens = 1) {
const now = Date.now();
let bucket = this.buckets.get(clientId);
if (!bucket) {
bucket = { tokens: this.capacity, lastRefill: now };
}
// Refill tokens based on elapsed time
const elapsed = (now - bucket.lastRefill) / 1000;
bucket.tokens = Math.min(
this.capacity,
bucket.tokens + elapsed * this.refillRate
);
bucket.lastRefill = now;
if (bucket.tokens < tokens) {
this.buckets.set(clientId, bucket);
return false;
}
bucket.tokens -= tokens;
this.buckets.set(clientId, bucket);
return true;
}
}Pros: Allows bursts up to bucket capacity. Smooth rate enforcement.
Cons: Two parameters to tune (capacity and refill rate).
Leaky Bucket
Requests enter a queue (bucket) and are processed at a fixed rate. If the queue is full, new requests are rejected.
Pros: Produces perfectly smooth output rate.
Cons: Adds latency (queuing), more complex to implement.
Implementation Patterns
Where to Rate Limit
API Gateway: First line of defense. Catches abuse before it reaches your services.
Application: More context-aware. Can limit based on user tier, endpoint cost, etc.
Database: Protect expensive queries. Often implemented as connection pools.
Production systems typically layer rate limiting at multiple levels.
Distributed Rate Limiting
With multiple API servers, rate limit state must be shared. Options:
Redis: Fast, widely supported, atomic operations.
// Redis sliding window with Lua script for atomicity
const rateLimitScript = `
local key = KEYS[1]
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])
-- Remove old entries
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
-- Count current entries
local count = redis.call('ZCARD', key)
if count < limit then
redis.call('ZADD', key, now, now .. '-' .. math.random())
redis.call('EXPIRE', key, window / 1000)
return 1
else
return 0
end
`;
async function isAllowed(clientId) {
const result = await redis.eval(
rateLimitScript,
1,
`ratelimit:${clientId}`,
Date.now(),
60000, // 1 minute window
100 // 100 requests per window
);
return result === 1;
}Local + Sync: Each server maintains local state, periodically syncs with others. Lower latency, eventual consistency.
Identifying Clients
What uniquely identifies a client for rate limiting purposes?
- API key: Best for authenticated APIs. Fair per-customer limits.
- User ID: After authentication. Prevents abuse per account.
- IP address: Before authentication. Beware of NAT/proxies.
- Combination: IP + endpoint for unauthenticated, API key + endpoint for authenticated.
function getClientIdentifier(request) {
// Prefer API key if available
if (request.apiKey) {
return `key:${request.apiKey}`;
}
// Fall back to user ID
if (request.userId) {
return `user:${request.userId}`;
}
// Last resort: IP address
const ip = request.headers['x-forwarded-for']?.split(',')[0]
|| request.connection.remoteAddress;
return `ip:${ip}`;
}Communicating Rate Limits
Good rate limit communication prevents surprises and helps clients self-correct.
Standard Headers
// Response headers
X-RateLimit-Limit: 100 // Requests allowed per window
X-RateLimit-Remaining: 75 // Requests remaining in current window
X-RateLimit-Reset: 1640000000 // Unix timestamp when window resets
// When limit exceeded
HTTP/1.1 429 Too Many Requests
Retry-After: 30 // Seconds until client should retryResponse Body for 429
{
"error": {
"code": "rate_limit_exceeded",
"message": "You have exceeded the rate limit of 100 requests per minute.",
"retry_after": 30,
"documentation_url": "https://docs.api.com/rate-limits"
}
}Documentation
Document rate limits clearly:
- What are the limits per tier?
- Are limits per endpoint or global?
- How are limits calculated (sliding window, etc.)?
- What happens when limits are exceeded?
- How can users request higher limits?
Advanced Patterns
Tiered Limits
Different limits for different customer tiers:
const tierLimits = {
free: { requestsPerMinute: 60, requestsPerDay: 1000 },
starter: { requestsPerMinute: 300, requestsPerDay: 10000 },
business: { requestsPerMinute: 1000, requestsPerDay: 100000 },
enterprise: { requestsPerMinute: 10000, requestsPerDay: null } // No daily limit
};
async function checkRateLimit(request) {
const tier = await getUserTier(request.userId);
const limits = tierLimits[tier];
const minuteOk = await checkLimit(
`minute:${request.userId}`,
limits.requestsPerMinute,
60000
);
const dayOk = limits.requestsPerDay === null || await checkLimit(
`day:${request.userId}`,
limits.requestsPerDay,
86400000
);
return minuteOk && dayOk;
}Cost-Based Limiting
Different endpoints have different costs. Limit by "cost units" rather than request count:
const endpointCosts = {
'GET /users': 1,
'GET /users/:id': 1,
'POST /reports': 10, // Expensive operation
'GET /search': 5, // Database-heavy
'POST /export': 50 // Very expensive
};
function getRequestCost(request) {
const pattern = matchRoutePattern(request);
return endpointCosts[pattern] || 1;
}
async function checkRateLimit(request) {
const cost = getRequestCost(request);
return tokenBucket.isAllowed(request.userId, cost);
}Graceful Degradation
Instead of hard rejection, offer degraded service:
async function handleRequest(request) {
const rateStatus = await checkRateLimit(request);
if (rateStatus === 'allowed') {
return fullResponse(request);
}
if (rateStatus === 'degraded') {
// Return cached/simplified response
return cachedResponse(request);
}
if (rateStatus === 'denied') {
return rateLimitResponse();
}
}Conclusion
Rate limiting is essential infrastructure. The right implementation protects your system while being invisible to well-behaved clients.
Key takeaways:
- Choose an algorithm that matches your needs (token bucket for burst tolerance, sliding window for smooth limits)
- Use distributed state (Redis) when running multiple servers
- Communicate limits clearly through headers and documentation
- Layer rate limiting at multiple levels (gateway, application, database)
- Consider cost-based limiting for expensive endpoints
The goal is protecting your system while making legitimate use effortless. When rate limiting is invisible to good actors and effective against bad ones, you've got it right.
Ready to implement these patterns? Explore enterprise software builds and Growth OS with WRKSHP, or start a conversation.
Work With WRKSHP
WRKSHP builds AI-native software, operated growth systems, and governance layers for teams that sell outcomes, not billable hours.
Explore enterprise software, Growth OS, GaaS governance, contact, or start a conversation about your next build.
Frequently Asked Questions
What is the main takeaway from "Rate Limiting Patterns: Protecting APIs Without Frustrating Users"?
Rate limiting patterns: Good rate limiting protects your system while being invisible to legitimate users. Use it as a production playbook for operators and engineers, not slide-deck theory.
How does "Rate limiting patterns overview" fit into Rate Limiting Patterns: Protecting APIs Without Frustrating Users?
Rate limiting patterns overview is a core section of this guide. Apply it after you pick one measurable KPI, then instrument the path that moves that KPI before expanding scope.
What should I watch when working through "Why Rate Limit?"?
Treat "Why Rate Limit?" as a decision checkpoint: name an owner, define success metrics, and refuse to automate steps that spend money or change production data without an audit trail.
When should I bring in a partner on Rate Limiting Patterns: Protecting APIs Without Frustrating Users?
Bring in help when you need a fixed-outcome delivery model, governance for agent actions, or a single platform spanning build and growth, patterns WRKSHP uses on enterprise software and Growth OS engagements.