WRKSHP
Engineering Feb 5, 2026 10 min read

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

Jansen Fitch

Founder, WRKSHP.DEV

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... 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 retry

Response 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.

#API Design#Backend#Rate Limiting#Security#Architecture