Skip to content
· 10 min read · 0 views

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

ScenarioRecommended AlgorithmWhy
Public APISliding Window CounterSmooth limits, fair usage
Auth endpointsToken Bucket (strict)Allow legitimate retries, block attacks
WebSocket connectionsConnection limit + Token BucketLimit connections, rate messages
Internal microservicesToken BucketAllow bursts, simple implementation
Billing APIsSliding Window LogPrecise 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:

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

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.

Discussion