Why Build It Myself?

Rate limiting is one of those things every backend developer uses but few implement from zero. I wanted to understand the actual mechanics - not the library API, but the algorithm, the storage implications, the header conventions, and the subtle failure mode that makes one algorithm strictly more correct than the other.

The result is a TypeScript + Express project with two interchangeable rate-limiting strategies, two pluggable storage backends, per-client/per-endpoint configuration, and a test suite that directly demonstrates the boundary burst problem.

The Architecture

The middleware pipeline is straightforward: Logger → Auth → Rate Limiter → Handler. What's interesting is how the rate limiter is wired.

Two routes demonstrate the difference:

  • GET /foo uses the Fixed Window strategy
  • GET /bar uses the Sliding Window Log strategy

Both share the same IRateLimitStrategy interface with a single method: isRateLimited(clientId, endpoint, maxRequests, windowMs). This means swapping algorithms is a one-word change in the route definition: rateLimit('fixed') vs rateLimit('sliding').

Fixed Window: Simple, Fast, Flawed

The FixedWindowRateLimiter divides time into discrete intervals. It stores two keys per client/endpoint pair: a counter and a window ID.

The implementation is barely 40 lines:

  1. Calculate currentWindow = Math.floor(now / windowMs)
  2. Compare against the stored window ID
  3. If it's a new window, reset the counter
  4. If counter >= maxRequests, return true (rate limited)
  5. Otherwise, increment and return false

Performance is excellent - O(1) time and O(1) space per client. But there's a catch.

The Boundary Burst

This is the bug I wanted to see with my own eyes. I wrote a test that sets the system time to 100ms before a window boundary, fires maxRequests requests, then advances time by 200ms (crossing the boundary) and fires one more.

The fixed window limiter says: "New window! Counter reset! Request allowed."

That means a client can fire up to 2x the intended limit in a 2-second span by timing requests around the boundary. In my test with maxRequests=10 and windowMs=60000, that's 20 requests in under a second. The test passes, confirming the vulnerability.

Sliding Window Log: Precise but Hungrier

The SlidingWindowRateLimiter stores an array of timestamps for each client/endpoint pair. On every request:

  1. Fetch the stored timestamps
  2. Filter out any older than now - windowMs
  3. If timestamps.length >= maxRequests, reject
  4. Otherwise, push now and update storage

The same boundary test I ran against fixed window? The sliding window correctly blocks the request. Those 10 timestamps from 100ms ago are still within the 60-second sliding window. No burst, no gaming the system.

The tradeoff is memory: instead of storing a single counter, you're storing up to maxRequests timestamps per client per endpoint. For 10,000 clients with a 100-request limit, that's up to 1 million timestamps in memory. In practice, Redis handles this easily, but it's worth understanding.

The Strategy Comparison Test

My favorite test in the suite is the strategy comparison. It creates both a FixedWindowRateLimiter and a SlidingWindowRateLimiter, runs the same sequence against both, and asserts their different behaviors:

The fixed window allows the request (new window started), while the sliding window still blocks (all requests within the sliding window). Seeing these two assertions side by side made the algorithmic difference viscerally real for me.

Pluggable Storage

Both strategies accept an IRateLimitStorage in their constructor. I implemented two backends:

MemoryStorage - a Map<string, number | number[]> that stores either counters (fixed window) or timestamp arrays (sliding window). Fast, zero-latency, but lost on restart and not shareable across instances.

RedisStorage - wraps the Node.js Redis client. Supports the same interface but persists data and shares state across processes. I added setWithExpiry for automatic key cleanup and proper connection lifecycle management with ensureConnection().

The storage interface is deliberately minimal: get, set, increment, decrement, reset. Any backend that can do these five things can plug in - PostgreSQL, DynamoDB, SQLite, whatever.

Per-Client, Per-Endpoint Configuration

Rate limits aren't one-size-fits-all. The clients.ts config file defines per-client, per-endpoint limits:

  • client-1: 10 req/min on /foo, 5 req/min on /bar
  • client-2: 20 req/min on /foo, 10 req/min on /bar

The middleware extracts the route from req.baseUrl, looks up the client's config, and passes the specific limits to whichever limiter is active. This mirrors how real API platforms offer different tiers.

Response Headers

Every response includes four rate-limit headers: X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Window-Ms, and X-RateLimit-Strategy. When rate-limited, the response also includes a Retry-After header. These follow the IETF draft spec and give client developers everything they need to implement backoff.

When to Use Which

ScenarioRecommendationWhy
High-throughput public APIsFixed WindowSimplicity and O(1) ops
Financial / compliance APIsSliding WindowPrecision matters more than memory
Multi-tenant platformsEither + per-client configLet the business tier determine the algorithm

Full source with tests: github.com/ionutn0301/rate-limit-project

Back to Insights