Rate Limiting Strategies for APIs: Protect Your Backend in 2026
Master API rate limiting with practical strategies and implementations. Compare token bucket, sliding window, and fixed window algorithms with real code examples in Go, Node.js, and Redis.
// table of contents (23 sections)
Rate Limiting Strategies for APIs: Protect Your Backend in 2026
Rate limiting is your first line of defense against API abuse, DDoS attacks, and runaway costs. Whether you’re running a public API with thousands of users or an internal microservice handling burst traffic, implementing the right rate limiting strategy determines your system’s reliability and user experience. In this guide, I’ll walk you through the most effective rate limiting strategies for 2026, with practical implementations and real-world trade-offs.
For foundational API architecture patterns, see my guide on building RESTful APIs with Go and Chi covering routing, middleware, and error handling.
Why Rate Limiting Matters
Rate limiting protects your API from:
- Traffic spikes that overwhelm your servers
- Malicious actors attempting DDoS or brute-force attacks
- Runaway clients with bugs causing infinite request loops
- Cost overruns from excessive compute or third-party API calls
- Fair usage ensuring all users get equal access
Without rate limiting, a single misbehaving client can degrade experience for all users—or worse, take down your entire service.
Rate Limiting Algorithms Compared
1. Token Bucket Algorithm
The token bucket is the most popular rate limiting algorithm. It allows burst traffic while maintaining an average rate over time.
How It Works:
- A bucket holds tokens (maximum capacity = burst size)
- Tokens are added at a fixed rate (refill rate)
- Each request consumes one token
- If no tokens available, request is denied
Implementation in Go:
package ratelimit
import (
"sync"
"time"
)
type TokenBucket struct {
capacity int64
tokens int64
refillRate int64
lastRefill time.Time
mu sync.Mutex
}
func NewTokenBucket(capacity, refillRate int64) *TokenBucket {
return &TokenBucket{
capacity: capacity,
tokens: capacity,
refillRate: refillRate,
lastRefill: time.Now(),
}
}
func (tb *TokenBucket) Allow() bool {
tb.mu.Lock()
defer tb.mu.Unlock()
now := time.Now()
elapsed := now.Sub(tb.lastRefill).Milliseconds()
if elapsed > 0 {
newTokens := (elapsed * tb.refillRate) / 1000
tb.tokens = min(tb.capacity, tb.tokens+newTokens)
tb.lastRefill = now
}
if tb.tokens > 0 {
tb.tokens--
return true
}
return false
}
func min(a, b int64) int64 {
if a < b {
return a
}
return b
}
Pros:
- Allows controlled bursts
- Memory efficient (single counter)
- Simple to understand and implement
Cons:
- Requires careful tuning of capacity and refill rate
- Not ideal for strict per-second limits
Best For: APIs that need to handle legitimate bursts (e.g., user refreshing a page multiple times)
2. Sliding Window Log
The sliding window log provides precise rate limiting by tracking each request timestamp within the window.
How It Works:
- Store timestamps of all requests
- When a new request arrives, remove timestamps outside the window
- Count remaining timestamps
- If count exceeds limit, deny request
Implementation with Redis:
const redis = require('redis');
const client = redis.createClient();
async function slidingWindowLog(userId, limit, windowMs) {
const key = `ratelimit:${userId}`;
const now = Date.now();
const windowStart = now - windowMs;
// Remove old entries outside the window
await client.zRemRangeByScore(key, 0, windowStart);
// Count requests in current window
const count = await client.zCard(key);
if (count >= limit) {
return false;
}
// Add current request
await client.zAdd(key, { score: now, value: `${now}-${Math.random()}` });
// Set expiry for cleanup
await client.expire(key, Math.ceil(windowMs / 1000));
return true;
}
// Usage: 100 requests per minute
app.use(async (req, res, next) => {
const userId = req.user?.id || req.ip;
const allowed = await slidingWindowLog(userId, 100, 60000);
if (!allowed) {
return res.status(429).json({ error: 'Rate limit exceeded' });
}
next();
});
Pros:
- Precise rate limiting (no bursts beyond limit)
- Accurate for any window size
- Easy to understand
Cons:
- Higher memory usage (stores all timestamps)
- More Redis operations per request
Best For: Strict rate limiting requirements (e.g., API billing, fair usage)
3. Fixed Window Counter
The simplest approach: count requests in fixed time intervals (e.g., per minute, per hour).
How It Works:
- Divide time into fixed windows
- Count requests per window
- Reset counter when window changes
Implementation in Node.js with Redis:
async function fixedWindowCounter(userId, limit, windowSeconds) {
const key = `ratelimit:fixed:${userId}:${Math.floor(Date.now() / 1000 / windowSeconds)}`;
const count = await client.incr(key);
if (count === 1) {
await client.expire(key, windowSeconds);
}
return count <= limit;
}
Pros:
- Extremely simple implementation
- Minimal memory usage
- Very fast (single Redis operation)
Cons:
- Allows double the limit at window boundaries
- Example: 10 requests/minute limit
- User sends 10 requests at 0:59
- User sends 10 more at 1:00
- 20 requests in 2 seconds!
Best For: Low-stakes rate limiting where boundary spikes are acceptable
4. Sliding Window Counter (Hybrid)
Combines the memory efficiency of fixed window with smoother rate limiting.
How It Works:
- Track counts for current and previous windows
- Calculate weighted sum based on position in current window
- Smooth out boundary spikes
Implementation:
async function slidingWindowCounter(userId, limit, windowSeconds) {
const now = Date.now();
const currentWindow = Math.floor(now / 1000 / windowSeconds);
const previousWindow = currentWindow - 1;
const currentKey = `ratelimit:sw:${userId}:${currentWindow}`;
const previousKey = `ratelimit:sw:${userId}:${previousWindow}`;
const [prevCount, currentCount] = await Promise.all([
client.get(previousKey).then(v => parseInt(v || '0')),
client.incr(currentKey)
]);
if (currentCount === 1) {
await client.expire(currentKey, windowSeconds * 2);
}
// Calculate weighted position in window
const windowStart = currentWindow * windowSeconds * 1000;
const elapsed = (now - windowStart) / 1000;
const weight = 1 - (elapsed / windowSeconds);
// Weighted sum
const estimatedCount = (prevCount * weight) + currentCount;
return estimatedCount <= limit;
}
Pros:
- Smooth rate limiting without boundary spikes
- Memory efficient (only two counters)
- Good balance of precision and performance
Cons:
- Approximation (not exact count)
- Slightly more complex than fixed window
Best For: Most production APIs—excellent balance of features
Rate Limiting with Middleware
Express.js Middleware
const rateLimit = require('express-rate-limit');
// Basic rate limiter
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // 100 requests per window
message: 'Too many requests, please try again later',
standardHeaders: true,
legacyHeaders: false,
keyGenerator: (req) => req.user?.id || req.ip,
});
// Strict limiter for auth endpoints
const authLimiter = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour
max: 5, // 5 attempts per hour
skipSuccessfulRequests: true, // Don't count successful logins
});
app.use('/api/', apiLimiter);
app.use('/auth/login', authLimiter);
Go Middleware with Chi
package middleware
import (
"net/http"
"time"
"github.com/go-chi/chi/v5"
"golang.org/x/time/rate"
)
func RateLimiter(rps int, burst int) func(http.Handler) http.Handler {
limiter := rate.NewLimiter(rate.Limit(rps), burst)
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !limiter.Allow() {
http.Error(w, "rate limit exceeded", http.StatusTooManyRequests)
return
}
next.ServeHTTP(w, r)
})
}
}
// Per-client rate limiting
func PerClientRateLimiter(rps int, burst int) func(http.Handler) http.Handler {
type client struct {
limiter *rate.Limiter
lastSeen time.Time
}
var (
mu sync.Mutex
clients = make(map[string]*client)
)
go func() {
for {
time.Sleep(time.Minute)
mu.Lock()
for ip, c := range clients {
if time.Since(c.lastSeen) > 3*time.Minute {
delete(clients, ip)
}
}
mu.Unlock()
}
}()
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ip := r.RemoteAddr
mu.Lock()
if _, exists := clients[ip]; !exists {
clients[ip] = &client{
limiter: rate.NewLimiter(rate.Limit(rps), burst),
}
}
clients[ip].lastSeen = time.Now()
limiter := clients[ip].limiter
mu.Unlock()
if !limiter.Allow() {
http.Error(w, "rate limit exceeded", http.StatusTooManyRequests)
return
}
next.ServeHTTP(w, r)
})
}
}
Distributed Rate Limiting
For microservices and distributed systems, use Redis for shared state.
Redis-Based Token Bucket
package ratelimit
import (
"context"
"fmt"
"time"
"github.com/redis/go-redis/v9"
)
type RedisTokenBucket struct {
client *redis.Client
capacity int64
refillRate int64
}
func NewRedisTokenBucket(client *redis.Client, capacity, refillRate int64) *RedisTokenBucket {
return &RedisTokenBucket{
client: client,
capacity: capacity,
refillRate: refillRate,
}
}
func (rtb *RedisTokenBucket) Allow(ctx context.Context, key string) (bool, error) {
script := redis.NewScript(`
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refillRate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local bucket = redis.call('HMGET', key, 'tokens', 'lastRefill')
local tokens = tonumber(bucket[1]) or capacity
local lastRefill = tonumber(bucket[2]) or now
local elapsed = now - lastRefill
local newTokens = math.floor(elapsed * refillRate / 1000)
tokens = math.min(capacity, tokens + newTokens)
if tokens >= 1 then
tokens = tokens - 1
redis.call('HMSET', key, 'tokens', tokens, 'lastRefill', now)
redis.call('PEXPIRE', key, 60000)
return 1
end
return 0
`)
now := time.Now().UnixMilli()
result, err := script.Run(ctx, rtb.client, []string{key}, rtb.capacity, rtb.refillRate, now).Int()
if err != nil {
return false, err
}
return result == 1, nil
}
Best Practices
1. Use Appropriate HTTP Headers
Always return rate limit information to clients:
res.set({
'X-RateLimit-Limit': limit,
'X-RateLimit-Remaining': remaining,
'X-RateLimit-Reset': resetTime,
});
For detailed error patterns, see my guide on error handling patterns in Go.
2. Implement Graceful Degradation
When rate limits are hit, return meaningful responses:
{
"error": {
"code": "RATE_LIMIT_EXCEEDED",
"message": "API rate limit exceeded. Please retry after 60 seconds.",
"retryAfter": 60,
"limit": 100,
"remaining": 0
}
}
3. Use Multiple Rate Limit Tiers
// Tier 1: Global rate limit (protect server)
app.use(globalLimiter(10000, 60 * 1000)); // 10k req/min globally
// Tier 2: Per-endpoint limits
app.use('/api/search', searchLimiter(10, 60 * 1000)); // 10 req/min for expensive search
// Tier 3: Per-user limits
app.use(userLimiter(100, 60 * 1000)); // 100 req/min per user
4. Different Limits for Different Users
const tierLimits = {
free: { requests: 100, window: 60 * 60 * 1000 }, // 100/hour
pro: { requests: 1000, window: 60 * 60 * 1000 }, // 1000/hour
enterprise: { requests: 10000, window: 60 * 60 * 1000 }, // 10000/hour
};
function getLimitForUser(user) {
const tier = user.subscription || 'free';
return tierLimits[tier];
}
5. Monitor and Alert
Track rate limit metrics:
metrics.increment('rate_limit.total', 1);
metrics.increment(`rate_limit.${endpoint}`, 1);
metrics.increment(`rate_limit.exceeded.${userId}`, 1);
Rate limiting complements your Docker container optimization strategy for backend protection.
Common Pitfalls
Pitfall 1: Rate Limiting by IP Behind Load Balancer
// BAD: All users share one IP (load balancer)
const ip = req.connection.remoteAddress;
// GOOD: Use X-Forwarded-For with trust settings
const ip = req.headers['x-forwarded-for']?.split(',')[0] || req.ip;
Pitfall 2: Not Handling Redis Failures
async function rateLimitWithFallback(userId) {
try {
return await redisRateLimiter.check(userId);
} catch (err) {
logger.error('Redis rate limit failed', err);
// Fallback to local rate limiting or allow request
return localRateLimiter.check(userId);
}
}
Pitfall 3: Rate Limiting Before Authentication
// BAD: Rate limit unauthenticated requests
app.use(rateLimiter);
app.use(authMiddleware);
// GOOD: Rate limit after auth to identify users
app.use(authMiddleware);
app.use(rateLimiter);
Decision Matrix
| Scenario | Recommended Algorithm | Why |
|---|---|---|
| Public API | Sliding Window Counter | Smooth limits, fair usage |
| Auth endpoints | Token Bucket (strict) | Allow legitimate retries, block attacks |
| WebSocket connections | Connection limit + Token Bucket | Limit connections, rate messages |
| Internal microservices | Token Bucket | Allow bursts, simple implementation |
| Billing APIs | Sliding Window Log | Precise counting for accurate billing |
Conclusion
Rate limiting in 2026 is more critical than ever as APIs face increasing traffic and sophisticated attacks. Choose your algorithm based on your specific needs:
- Token Bucket for burst-tolerant APIs
- Sliding Window Counter for smooth, fair rate limiting
- Sliding Window Log for precise, audit-ready limits
- Fixed Window for simple, low-stakes scenarios
Key Takeaways:
- Always return rate limit headers to help clients
- Implement multiple tiers of protection
- Use Redis for distributed rate limiting
- Monitor and alert on rate limit events
- Handle failures gracefully with fallbacks
Next Steps:
- Review your API architecture for rate limiting integration points
- Implement rate limiting for your Docker deployments
- Set up monitoring for rate limit events alongside your CI/CD pipeline
Rate limiting is not optional—it’s essential infrastructure for any production API. Start simple, measure, and iterate based on your traffic patterns.
Building a rate-limited API? Connect with me on Twitter or check out more backend tutorials on this blog.
You might also like
Building Type-Safe APIs with tRPC in 2026: Full-Stack TypeScript Without Schemas
Learn how tRPC eliminates the need for API schemas by leveraging TypeScript's type system. Build end-to-end type-safe APIs with automatic client generation.
Workflow Orchestration 2026: Temporal vs Inngest vs Trigger.dev
Compare leading workflow orchestration platforms for modern applications. Learn when to use Temporal, Inngest, or Trigger.dev for background jobs, event-driven architectures, and durable workflows.
Background Job Processing Patterns: A Practical Guide for 2026
Master background job processing for scalable applications. Learn queue patterns, retry strategies, dead letter queues, and best practices for reliable async workflows.
More Posts
API Gateway Patterns: The Front Door to Your Microservices
Web Components 2026: Building Framework-Agnostic UI Libraries
Building Autonomous AI Workflows with LangGraph: A Practical Guide
Building Type-Safe APIs with tRPC in 2026: Full-Stack TypeScript Without Schemas
Database Connection Pooling: Patterns for High-Performance Applications
Prompt Caching: Reduce LLM Costs by 90% with Smart Context Management
Enjoyed This Post?
Want to discuss the topic, have questions, or looking to collaborate on something similar? Drop a comment below or reach out directly.
