Why API Rate Limiting Is Not Optional

Every HTTP endpoint you expose—whether a public API, an internal microservice, or a webhook receiver—can be exhausted by an unexpected flood of requests. That flood might be a misconfigured retry loop, a scraper, a denial-of-service attempt, or simply a marketing campaign that sent far more traffic than anticipated. Without a rate-limiting layer, a single over-eager client can degrade the experience for everyone else, or take your entire service down.

Rate limiting enforces a maximum allowed request rate per client, per route, or per tier. Throttling is the broader term that also includes queuing, delaying, and gracefully degrading rather than outright rejecting. Together they are foundational to any production API — not an afterthought to bolt on later.

This guide walks through the three most practical algorithms (fixed window, sliding window, and token bucket), shows how to implement each in Node.js backed by Redis, and discusses where each one fits.

---

Understanding the Three Core Algorithms

1. Fixed Window Counter

The simplest approach: divide time into equal windows (e.g., one minute each) and count requests per window per key.

Window [12:00 – 12:01] → client_A: 47 requests
Window [12:01 – 12:02] → client_A: 0 requests (reset)

How it works:

  • Store a counter in Redis with a TTL matching the window duration.
  • Increment the counter on every request.
  • Reject requests that exceed the threshold.
// Illustrative example — not production-ready as-is
async function fixedWindowAllow(redis, key, limit, windowSeconds) {
  const redisKey = `ratelimit:fixed:${key}`;
  const count = await redis.incr(redisKey);

  if (count === 1) {
    // First request in a fresh window — set expiry
    await redis.expire(redisKey, windowSeconds);
  }

  return count <= limit;
}

Trade-off: A client can make 2× the limit in a short burst by sending limit requests at the very end of one window and limit more at the very start of the next. This "boundary burst" is the algorithm's main weakness.

---

2. Sliding Window Log

Records the exact timestamp of every request for a client. When a new request arrives, prune entries older than the window duration and count what remains.

// Illustrative example — not production-ready as-is
async function slidingWindowLogAllow(redis, key, limit, windowMs) {
  const now = Date.now();
  const windowStart = now - windowMs;
  const redisKey = `ratelimit:swlog:${key}`;

  // Atomic pipeline: remove old entries, add current, count
  const [, , count] = await redis
    .multi()
    .zremrangebyscore(redisKey, '-inf', windowStart)
    .zadd(redisKey, now, `${now}-${Math.random()}`)
    .zcard(redisKey)
    .expire(redisKey, Math.ceil(windowMs / 1000) + 1)
    .exec();

  return count <= limit;
}

Trade-off: Accurate — no boundary burst. However, it stores one Redis entry per request. For high-throughput APIs (thousands of requests per second per client), memory usage grows proportionally to the request count. For most SaaS use cases the cost is acceptable; for very high-volume pipelines, prefer the sliding window counter below.

---

3. Token Bucket

A "bucket" holds up to capacity tokens. Each second, refillRate tokens are added (up to the capacity). Each request consumes one token. If the bucket is empty, the request is rejected (or queued).

The bucket naturally handles bursts up to the capacity while enforcing a steady-state throughput equal to the refill rate — the closest model to real-world API quota behavior.

// Illustrative example — not production-ready as-is
async function tokenBucketAllow(redis, key, capacity, refillRatePerSec) {
  const redisKey = `ratelimit:bucket:${key}`;
  const now = Date.now() / 1000; // seconds with decimals

  const script = `
    local tokens = tonumber(redis.call('hget', KEYS[1], 'tokens'))
    local lastRefill = tonumber(redis.call('hget', KEYS[1], 'ts'))
    local capacity = tonumber(ARGV[1])
    local rate = tonumber(ARGV[2])
    local now = tonumber(ARGV[3])

    if not tokens then
      tokens = capacity
      lastRefill = now
    end

    -- Refill based on elapsed time
    local elapsed = math.max(0, now - lastRefill)
    tokens = math.min(capacity, tokens + elapsed * rate)
    lastRefill = now

    local allowed = 0
    if tokens >= 1 then
      tokens = tokens - 1
      allowed = 1
    end

    redis.call('hset', KEYS[1], 'tokens', tokens, 'ts', lastRefill)
    redis.call('expire', KEYS[1], math.ceil(capacity / rate) + 10)

    return allowed
  `;

  const result = await redis.eval(
    script,
    1,
    redisKey,
    capacity,
    refillRatePerSec,
    now,
  );

  return result === 1;
}

The Lua script runs atomically on the Redis server, eliminating race conditions without additional transactions.

Trade-off: Slightly more complex to reason about. Because the refill happens on demand (lazy evaluation), the implementation above approximates continuous refill without a background job.

---

4. Sliding Window Counter (Hybrid — Best of Both)

A practical middle ground: keep only two counters (previous window and current window) and interpolate the request count based on how far into the current window the request falls.

rate_estimate = (prev_count × overlap_ratio) + curr_count

Where overlap_ratio is the fraction of the previous window that falls within the sliding window ending now.

This eliminates the fixed-window boundary burst with O(1) Redis storage per key — no growing sorted set.

// Illustrative example — not production-ready as-is
async function slidingWindowCounterAllow(redis, key, limit, windowSeconds) {
  const now = Math.floor(Date.now() / 1000);
  const currentWindow = Math.floor(now / windowSeconds);
  const prevWindow = currentWindow - 1;

  const currKey = `ratelimit:swc:${key}:${currentWindow}`;
  const prevKey = `ratelimit:swc:${key}:${prevWindow}`;

  const [rawPrev, rawCurr] = await redis.mget(prevKey, currKey);
  const prevCount = parseInt(rawPrev ?? '0', 10);
  const currCount = parseInt(rawCurr ?? '0', 10);

  // Position within the current window (0.0 → 1.0)
  const position = (now % windowSeconds) / windowSeconds;
  const overlapRatio = 1 - position;

  const estimate = prevCount * overlapRatio + currCount;

  if (estimate >= limit) {
    return false;
  }

  await redis.multi().incr(currKey).expire(currKey, windowSeconds * 2).exec();
  return true;
}

---

Choosing the Right Algorithm

| Algorithm | Accuracy | Memory | Burst tolerance | Complexity | Best for | |---|---|---|---|---|---| | Fixed window | Low | O(1) | Vulnerable to boundary burst | Low | Internal services, simple quotas | | Sliding window log | High | O(n requests) | Accurate | Medium | Low-to-medium traffic APIs | | Token bucket | High | O(1) | Explicit burst capacity | Medium | Public APIs, SDKs with retry | | Sliding window counter | High | O(1) | Minimal boundary artifact | Medium | Most production APIs |

For the majority of SaaS products and business APIs, the sliding window counter is the right default: it is accurate enough, memory-efficient, and straightforward to implement.

The token bucket is preferred when you want to explicitly offer burst capacity to clients — for example, allowing a client to make 50 requests in a single second as long as they don't exceed 1,000 per hour on average. This is the model GitHub, Stripe, and Cloudflare use.

---

Structuring Rate Limit Keys

A rate limit key identifies _who_ is being limited. Common strategies:

  • IP address — simplest; can be shared by NAT or proxies.
  • API key — preferred for authenticated APIs; ties limits to a subscription tier.
  • User ID — useful for authenticated user-facing features.
  • IP + route — per-endpoint limits for sensitive actions (login, password reset, OTP verification).
  • Tenant + route — for multi-tenant SaaS; limit each organisation independently.
// Key examples (illustrative)
`ratelimit:${apiKey}:global`           // global per-key quota
`ratelimit:${ip}:/auth/login`          // per-IP brute-force protection
`ratelimit:${tenantId}:api:${route}`   // per-tenant, per-route

Namespace all keys with a prefix so they are easy to scan and flush during incidents.

---

HTTP Response Conventions

RFC 6585 defines 429 Too Many Requests as the correct status code. Always include headers so clients can implement respectful back-off:

HTTP/1.1 429 Too Many Requests
Content-Type: application/json
Retry-After: 30
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1731254400

{
  "error": "rate_limit_exceeded",
  "message": "You have exceeded 100 requests per minute. Try again in 30 seconds.",
  "retry_after": 30
}
  • Retry-After: seconds (or HTTP-date) until the client may retry. This is the most important header; well-behaved HTTP clients and SDK retry libraries honour it automatically.
  • X-RateLimit-Limit: the ceiling for the current window.
  • X-RateLimit-Remaining: how many requests remain in this window.
  • X-RateLimit-Reset: Unix timestamp when the counter resets.

Include these headers on all successful responses too (not only on 429). Clients can then surface quota consumption in dashboards before they hit the limit.

---

Express Middleware Integration

The logic above is easier to consume as reusable middleware:

// Illustrative example — not production-ready as-is
import { createClient } from 'redis';

const redis = createClient({ url: process.env.REDIS_URL });
await redis.connect();

function rateLimitMiddleware({ limit = 100, windowSeconds = 60, keyFn } = {}) {
  return async (req, res, next) => {
    const key = keyFn ? keyFn(req) : req.ip;

    const allowed = await slidingWindowCounterAllow(
      redis,
      key,
      limit,
      windowSeconds,
    );

    // Always set informational headers (simplified — omits remaining count)
    res.set('X-RateLimit-Limit', String(limit));

    if (!allowed) {
      res.set('Retry-After', String(windowSeconds));
      return res.status(429).json({
        error: 'rate_limit_exceeded',
        message: `Limit of ${limit} requests per ${windowSeconds}s exceeded.`,
        retry_after: windowSeconds,
      });
    }

    next();
  };
}

// Usage
app.use('/api/', rateLimitMiddleware({ limit: 100, windowSeconds: 60 }));

app.use(
  '/auth/login',
  rateLimitMiddleware({
    limit: 10,
    windowSeconds: 300,
    keyFn: (req) => `${req.ip}:/auth/login`,
  }),
);

---

Redis Deployment Considerations

Rate limiters depend on a shared, low-latency store. Redis is the standard choice for three reasons:

  1. Atomic operations — INCR, EXPIRE, sorted-set commands, and EVAL (Lua) all execute atomically, eliminating race conditions.
  2. Sub-millisecond latency — a Redis lookup adds less overhead than a database query by an order of magnitude.
  3. TTL-based cleanup — expired keys are removed automatically; no background sweep needed.

For clustered Redis (Redis Cluster): keys used in a Lua script or multi-key pipeline must hash to the same slot. Use hash tags to force co-location:

// Force prev and curr keys to the same slot using hash tag {key}
const currKey = `ratelimit:{${key}}:${currentWindow}`;
const prevKey = `ratelimit:{${key}}:${prevWindow}`;

For Redis Sentinel / high-availability setups: a brief failover may allow slightly more than the limit to pass. This is generally acceptable; rate limiting is a soft guarantee, not a hard security boundary.

If Redis is not in your stack, Upstash (a serverless Redis-compatible service with an HTTP API) works well for edge environments and serverless functions where long-lived TCP connections are impractical.

---

Tiered Quotas for Multi-Tenant APIs

A SaaS API serving multiple subscription tiers needs different limits per tier. Store limits in your application database (or config) and look them up per request:

// Illustrative example — not production-ready as-is
async function getTierLimits(apiKey) {
  // Returns e.g. { limit: 1000, windowSeconds: 60 }
  return db.query('SELECT rate_limit, rate_window FROM api_keys WHERE key = $1', [apiKey]);
}

async function tieredRateLimitMiddleware(req, res, next) {
  const apiKey = req.headers['x-api-key'];
  if (!apiKey) return res.status(401).json({ error: 'api_key_required' });

  const tier = await getTierLimits(apiKey);
  if (!tier) return res.status(401).json({ error: 'invalid_api_key' });

  const allowed = await slidingWindowCounterAllow(
    redis,
    apiKey,
    tier.rate_limit,
    tier.rate_window,
  );

  if (!allowed) {
    return res.status(429).json({
      error: 'rate_limit_exceeded',
      retry_after: tier.rate_window,
    });
  }

  next();
}

Cache the tier lookup (e.g., with a 60-second in-memory cache) to avoid a database round-trip on every request.

---

Testing Your Rate Limiter

A rate limiter that does not work under concurrent load is worse than none — it gives false confidence. Test it properly:

Unit test — invoke the limiter function directly for limit + 1 times in a loop and assert the last call returns false.

Concurrency test — fire requests in parallel using Promise.all to verify that atomicity holds. Without atomic Redis operations, two concurrent requests near the limit might both see remaining > 0 and both succeed.

// Illustrative concurrency test
test('sliding window counter rejects concurrent over-limit requests', async () => {
  const key = `test:${Date.now()}`;
  const limit = 10;

  const results = await Promise.all(
    Array.from({ length: limit + 5 }, () =>
      slidingWindowCounterAllow(redis, key, limit, 60),
    ),
  );

  const allowed = results.filter(Boolean).length;
  expect(allowed).toBeLessThanOrEqual(limit);
});

Load test — use a tool like k6 or Artillery to generate sustained load from multiple virtual users. Confirm the 429 rate matches expectations and that the Retry-After header causes clients to back off properly.

---

Common Pitfalls

1. Rate limiting after authentication, not before. Expensive operations (password hashing, database lookups) in the authentication middleware run before the rate limiter has a chance to reject. Put IP-based rate limiting early in the middleware stack, before any database work.

2. Not logging 429 responses. Rate limit events are valuable signals. Log the client key, the route, and the timestamp. Spikes in 429s often indicate a misconfigured client, an attack, or a legitimate client hitting a limit that should be raised.

3. Using a single global counter for all routes. A client exhausting their quota on a low-priority bulk export endpoint should not block their time-sensitive payment confirmation calls. Apply separate limits to separate route groups.

4. Forgetting to handle Redis unavailability. If Redis is down, your rate limiter call will throw. Decide in advance: fail open (allow all requests) or fail closed (block all). Failing open preserves availability; failing closed protects the backend. Wrap the limiter in a try/catch with an explicit fallback.

try {
  const allowed = await slidingWindowCounterAllow(redis, key, limit, window);
  if (!allowed) return res.status(429).json({ error: 'rate_limit_exceeded' });
} catch (err) {
  // Redis unavailable — fail open to preserve availability
  console.error('Rate limiter unavailable:', err);
}
// Continue to handler

---

Summary

| Requirement | Recommendation | |---|---| | Simple internal service | Fixed window counter | | Public API, medium traffic | Sliding window counter | | SDK with burst tolerance | Token bucket | | Sensitive endpoints (login, OTP) | Sliding window counter, tight limits, by IP + route | | Multi-tenant SaaS | Sliding window counter with per-key limits from database |

Rate limiting is one of the first layers of resilience you should add to any production API. Start with the sliding window counter, pair it with structured logging of 429 events, and iterate limit values based on actual traffic patterns.

---

Build Resilient APIs with Dragonfly Soft

Designing and operating a production backend — including rate limiting, authentication, caching, and observability — requires both engineering depth and operational experience. At Dragonfly Soft we have delivered API-first platforms for businesses across Syria and the region, from fintech gateways to multi-tenant SaaS products.

If you are building a new API or hardening an existing one, contact us to discuss how we can help you design a system that stays reliable under real-world load.