<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Runtime Journal]]></title><description><![CDATA[Runtime Journal]]></description><link>https://desirarman.hashnode.dev</link><image><url>https://cdn.hashnode.com/uploads/logos/6a9add4bb55da54b763bf637/7f93bcf2-d677-4666-a363-e4fb174d5d11.png</url><title>Runtime Journal</title><link>https://desirarman.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Thu, 10 Sep 2026 17:03:34 GMT</lastBuildDate><atom:link href="https://desirarman.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Building a Single-Node Rate Limiter with Redis in Node.js]]></title><description><![CDATA[Introduction
Rate limiting is a way of controlling how frequently a client can access an API.
Without rate limiting, a single client could send a large number of requests in a short period of time. Th]]></description><link>https://desirarman.hashnode.dev/building-a-single-node-rate-limiter-with-redis-in-node-js</link><guid isPermaLink="true">https://desirarman.hashnode.dev/building-a-single-node-rate-limiter-with-redis-in-node-js</guid><category><![CDATA[Node.js]]></category><category><![CDATA[Redis]]></category><category><![CDATA[rate-limiting]]></category><category><![CDATA[Express.js]]></category><category><![CDATA[Backend Development]]></category><dc:creator><![CDATA[Abhishek]]></dc:creator><pubDate>Fri, 04 Sep 2026 17:21:17 GMT</pubDate><content:encoded><![CDATA[<h2>Introduction</h2>
<p>Rate limiting is a way of controlling how frequently a client can access an API.</p>
<p>Without rate limiting, a single client could send a large number of requests in a short period of time. This can consume server resources, overload an API, or make it easier for abusive traffic to affect other users.</p>
<p>In this project, I wanted to understand how rate limiting actually works by implementing three common algorithms using <strong>Node.js, Express, and Redis</strong>:</p>
<ul>
<li><p><strong>Fixed Window</strong></p>
</li>
<li><p><strong>Sliding Window</strong></p>
</li>
<li><p><strong>Token Bucket</strong></p>
</li>
</ul>
<p>The application is currently a <strong>single-node setup</strong>. The Node.js application handles incoming requests, while Redis stores the rate-limiting state.</p>
<p>The basic architecture looks like this:</p>
<pre><code class="language-text">Client
   |
   v
Node.js / Express
   |
   v
Rate Limiter Middleware
   |
   v
Redis
</code></pre>
<p>The middleware determines who is making the request, checks the corresponding rate-limiter algorithm, and either allows the request to continue or returns an HTTP <code>429 Too Many Requests</code> response.</p>
<p>The goal of this project was not just to make an endpoint return <code>429</code>. It was to understand how different rate-limiting algorithms store state, how time affects their behavior, and where concurrency and atomicity become important.</p>
<p>In the following sections, I will walk through each algorithm and how I implemented it with Redis.</p>
<h3>Project</h3>
<p>The complete implementation, tests, and examples are available in the GitHub repository: <a href="https://github.com/DesirArman007/redis-rate-limiter"><mark class="bg-yellow-200 dark:bg-yellow-500/30">Github Repo</mark></a></p>
<p>The project currently provides a Redis-backed rate limiter for a single Node.js application instance, with support for Fixed Window, Sliding Window, and Token Bucket algorithms.</p>
<h2>Visualizing the Algorithms</h2>
<p>Before implementing the algorithms, I used an interactive rate limiter simulator to understand how their behavior differs under different traffic patterns.</p>
<p>The simulator helped me visualize:</p>
<ul>
<li><p>how Token Bucket allows bursts and gradually refills tokens</p>
</li>
<li><p>how Fixed Window resets at fixed time boundaries</p>
</li>
<li><p>how Sliding Window evaluates requests over a rolling period</p>
</li>
<li><p>how requests are accepted or rejected with HTTP 429</p>
</li>
</ul>
<p>You can experiment with the simulator here: <a href="https://theinfinity.dev/simulators/rate-limiter"><mark class="bg-yellow-200 dark:bg-yellow-500/30">Rate Limiter Simulator</mark></a></p>
<h2>Project Setup</h2>
<p>For this project, I used Node.js and Express for the application layer and Redis to store the rate-limiting state.</p>
<p>The main technologies used were:</p>
<ul>
<li><p>Node.js</p>
</li>
<li><p>Express.js</p>
</li>
<li><p>Redis</p>
</li>
<li><p>ioredis</p>
</li>
<li><p>Bruno for API testing</p>
</li>
</ul>
<p>The project started as a simple Express application with three endpoints, one for each rate-limiting algorithm:</p>
<ul>
<li><p><code>/api/fixed-test</code> — Fixed Window</p>
</li>
<li><p><code>/api/sliding-test</code> — Sliding Window</p>
</li>
<li><p><code>/api/token-bucket-test</code> — Token Bucket</p>
</li>
</ul>
<p>Each endpoint uses the same rate-limiter middleware, but the middleware is configured with a different algorithm.</p>
<p>The basic project structure eventually became:</p>
<pre><code class="language-text">RateLimiter/
├── src/
│   ├── index.js
│   ├── fixed-window.js
│   ├── sliding-window.js
│   └── token-bucket.js
├── examples/
│   └── basic.js
├── test/
│   ├── fixed-window.test.js
│   ├── sliding-window.test.js
│   └── token-bucket.test.js
├── README.md
├── LICENSE
├── package.json
└── package-lock.json
</code></pre>
<h2>Understanding the Express Middleware Flow</h2>
<p>Before looking at the individual rate-limiting algorithms, it is useful to understand how a request moves through the application.</p>
<p>The main function exposed by the package is <code>rateLimiter()</code>. It acts as a <strong><mark class="bg-yellow-200 dark:bg-yellow-500/30">middleware factory</mark></strong>: it receives the rate-limiter configuration and returns an Express middleware function.</p>
<p>A simplified flow looks like this:</p>
<pre><code class="language-plaintext">Client Request
     |
     v
rateLimiter(...)
     |
     v
Generate client key
     |
     v
Run selected algorithm
     |
     +--------+
     |        |
   Allow    Reject
     |        |
     v        v
  next()     429
     |
     v
Route Handler
</code></pre>
<p>The middleware can be configured with different algorithms:</p>
<pre><code class="language-javascript">const limiter = rateLimiter({
    redis,
    type: 'fixed',
    limit: 5,
    windowSize: 10000
});
</code></pre>
<p>The <code>redis</code> client is passed into the limiter so that the rate limiter can use the application's existing Redis connection instead of creating a new Redis connection for every limiter.</p>
<p>The <code>type</code> determines which algorithm should be used:</p>
<pre><code class="language-javascript">type: 'fixed'
</code></pre>
<p>or</p>
<pre><code class="language-javascript">type: 'sliding'
</code></pre>
<p>or</p>
<pre><code class="language-javascript">type: 'token'
</code></pre>
<p>When a request arrives, the middleware first generates a key to identify the client:</p>
<pre><code class="language-javascript">const key = keyGenerator ? keyGenerator(req) : req.ip;
</code></pre>
<p>By default, the client's IP address is used. A custom <code>keyGenerator</code> can also be provided when the application needs a different identity, such as a user ID or API key.</p>
<p>The selected algorithm then returns either <code>true</code> or <code>false</code>.</p>
<p>If the request is allowed:</p>
<pre><code class="language-javascript">next();
</code></pre>
<p>is called. <code>next()</code> tells Express to continue processing the <strong>same request</strong> and move to the next function in the middleware chain, which in this case is the route handler.</p>
<p>If the request is rejected, the middleware stops the chain and returns:</p>
<pre><code class="language-javascript">return res.status(429).json({
    error: 'Rate limit exceeded'
});
</code></pre>
<p>This means the route handler never executes for a request that has exceeded the configured limit.</p>
<p>With this common middleware flow in place, the three algorithms can use the same interface while implementing their rate-limiting logic differently.</p>
<h2>Fixed Window: The Simplest Counter</h2>
<p>The Fixed Window algorithm is the simplest of the three algorithms implemented in this project.</p>
<p>The basic idea is straightforward:</p>
<blockquote>
<p><mark class="bg-yellow-200 dark:bg-yellow-500/30">Allow a fixed number of requests during a fixed period of time.</mark></p>
</blockquote>
<p>For example, the limiter can be configured as:</p>
<pre><code class="language-javascript">{
    type: 'fixed',
    limit: 5,
    windowSize: 10000
}
</code></pre>
<p>This means a client can make <strong>5 requests within a 10-second window</strong>.</p>
<h3>How it works</h3>
<p>For every client, Redis maintains a counter.</p>
<p>The implementation creates a Redis key based on the client identifier:</p>
<pre><code class="language-javascript">const key = "Rate:" + userId;
</code></pre>
<p>When a request arrives, the counter is incremented:</p>
<pre><code class="language-javascript">const count = await redis.incr(key);
</code></pre>
<p>If this is the first request in the window, the key receives an expiration time:</p>
<pre><code class="language-javascript">if (count === 1) {
    await redis.expire(key, Math.ceil(windowSize / 1000));
}
</code></pre>
<p>The counter is then compared with the configured limit:</p>
<pre><code class="language-javascript">if (count &gt; limit) {
    return false;
}

return true;
</code></pre>
<p>So with a limit of <code>5</code>, the requests behave like this:</p>
<pre><code class="language-plaintext">Request 1 → count = 1 → allowed
Request 2 → count = 2 → allowed
Request 3 → count = 3 → allowed
Request 4 → count = 4 → allowed
Request 5 → count = 5 → allowed
Request 6 → count = 6 → rejected
</code></pre>
<p>Once the Redis key expires, the counter disappears and the client gets a new window.</p>
<h3>Redis state</h3>
<p>The Redis state is very simple:</p>
<pre><code class="language-text">Rate:&lt;client&gt;
      |
      └── integer counter
</code></pre>
<p>For example:</p>
<pre><code class="language-text">Rate:192.168.1.10 → 4
</code></pre>
<p>The key also has a TTL representing the remaining lifetime of the current window.</p>
<h3>The main limitation</h3>
<p>The biggest issue with Fixed Window is the <strong>window boundary</strong>.</p>
<p>For example, suppose the limit is 5 requests per 10 seconds:</p>
<pre><code class="language-text">00:00 → 00:10
</code></pre>
<p>A client could potentially make:</p>
<pre><code class="language-text">00:09.9 → 5 requests
00:10.0 → 5 requests
</code></pre>
<p>That is 10 requests very close together, even though the configured limit is only 5 per 10-second window.</p>
<p>This is known as a <strong>boundary burst</strong>.</p>
<p>The advantage is that Fixed Window is extremely simple and inexpensive to implement. The tradeoff is that its time boundaries make it less precise than a sliding window.</p>
<h2>Sliding Window: Timestamps in a Sorted Set</h2>
<p>The Sliding Window algorithm improves on the boundary problem of the Fixed Window approach by looking at the <strong>actual timestamps of recent requests</strong> rather than maintaining only one counter.</p>
<p>Instead of dividing time into fixed blocks, we maintain a moving window of time.</p>
<p>For example, with:</p>
<pre><code class="language-javascript">{
    type: 'sliding',
    limit: 5,
    windowSize: 10000
}
</code></pre>
<p>the limiter asks:</p>
<blockquote>
<p><mark class="bg-yellow-200 dark:bg-yellow-500/30">How many requests has this client made during the last 10 seconds?</mark></p>
</blockquote>
<h3>Storing timestamps in Redis</h3>
<p>For the Sliding Window implementation, Redis uses a <strong>Sorted Set</strong>.</p>
<p>The key is:</p>
<pre><code class="language-javascript">const key = `Rate:Sliding:${userId}`;
</code></pre>
<p>Each request is stored with its timestamp as the sorted-set score.</p>
<p>Conceptually, the Redis state looks like:</p>
<pre><code class="language-text">Rate:Sliding:user123

timestamp        request
---------        -------
1710000010000    request-1
1710000012000    request-2
1710000015000    request-3
1710000018000    request-4
</code></pre>
<p>The timestamp allows Redis to order the requests chronologically.</p>
<h3>Moving the window</h3>
<p>When a new request arrives, the Lua script first calculates the beginning of the current window:</p>
<pre><code class="language-plaintext">local windowStart = now - WINDOW_SIZE
</code></pre>
<p>For a 10-second window:</p>
<pre><code class="language-text">now = current timestamp
windowStart = now - 10 seconds
</code></pre>
<p>Requests older than that point are removed:</p>
<pre><code class="language-lua">redis.call('ZREMRANGEBYSCORE', key, 0, windowStart)
</code></pre>
<p>The remaining requests are then counted:</p>
<pre><code class="language-lua">local count = redis.call('ZCARD', key)
</code></pre>
<p>If the count is below the configured limit, the new request is added:</p>
<pre><code class="language-lua">redis.call('ZADD', key, now, randomValue)
</code></pre>
<p>So the overall process is:</p>
<pre><code class="language-text">New request
    |
    v
Calculate window start
    |
    v
Remove old timestamps
    |
    v
Count remaining timestamps
    |
    v
Count &lt; limit?
   /       \
 Yes       No
  |         |
  v         v
Add       Reject
timestamp
  |
  v
Allow
</code></pre>
<h3>Why use a Sorted Set?</h3>
<p>A Redis Sorted Set is useful here because each request can be associated with a timestamp and Redis keeps the entries ordered by their score.</p>
<p>That gives us the operations we need:</p>
<ul>
<li><p>Remove timestamps older than the window</p>
</li>
<li><p>Count requests currently inside the window</p>
</li>
<li><p>Add the new request timestamp</p>
</li>
</ul>
<p>Compared with Fixed Window, we are no longer dependent on an arbitrary boundary such as <code>00:00</code> or <code>00:10</code>.</p>
<p>The window moves with each request.</p>
<h3>The important problem: concurrency</h3>
<p>There is another issue, though.</p>
<p>These operations need to behave as <strong>one atomic operation</strong>:</p>
<pre><code class="language-text">Remove old requests
       ↓
Count requests
       ↓
If allowed → add new request
</code></pre>
<p>If these were separate Redis commands, two requests arriving at almost the same time could both observe the same count before either request is added.</p>
<p>That's where the Lua script becomes important.</p>
<h2>Why the Sliding-Window Lua Script Must Be Atomic</h2>
<p>The Sliding Window algorithm performs several Redis operations for every request:</p>
<pre><code class="language-text">Remove expired timestamps
        ↓
Count remaining requests
        ↓
Check the limit
        ↓
Add the new timestamp
</code></pre>
<p>If these operations were sent to Redis as separate commands, concurrent requests could interfere with each other.</p>
<p>For example, suppose the limit is <code>5</code> and Redis currently contains <code>4</code> requests.</p>
<p>Two requests arrive at almost the same time:</p>
<pre><code class="language-text">Request A → sees count = 4 → allowed
Request B → sees count = 4 → allowed
</code></pre>
<p>Both requests may decide that there is room for another request before either one adds its timestamp. This can result in the limit being exceeded.</p>
<h3>Using Lua</h3>
<p>I put the complete decision-making process into a Redis Lua script:</p>
<pre><code class="language-lua">redis.call('ZREMRANGEBYSCORE', key, 0, windowStart)

local count = redis.call('ZCARD', key)

if (count &lt; limit) then
    local randomValue = now .. "-" .. math.random()

    redis.call('ZADD', key, now, randomValue)

    redis.call('EXPIRE', key, math.ceil(WINDOW_SIZE/1000)+1)

    return 1
else
    return 0
end
</code></pre>
<p>Instead of the Node.js application performing each operation independently, <mark class="bg-yellow-200 dark:bg-yellow-500/30">it sends the operation to Redis as one script.</mark></p>
<p>Conceptually:</p>
<pre><code class="language-text">Node.js
   |
   | one Lua script
   ↓
Redis
   |
   ├── remove old timestamps
   ├── count requests
   ├── check limit
   └── add timestamp
</code></pre>
<p>Redis executes the Lua script atomically, so another Redis command cannot execute in the middle of that script.</p>
<p>The Node.js code then only needs to interpret the result:</p>
<pre><code class="language-javascript">const allowed = await redis.slidingWindow(
    key,
    now,
    windowSize,
    limit
);

return allowed === 1;
</code></pre>
<p>Here, <code>1</code> means the request was allowed and <code>0</code> means it was rejected.</p>
<h3>Why this matters</h3>
<p>The Lua script is not just an optimization. It protects the <strong>correctness of the rate limiter under concurrent requests</strong>.</p>
<p>This was one of the important differences between simply implementing the algorithm and understanding how it behaves when multiple requests arrive at the same time.</p>
<h2>Token Bucket: Bursts and Gradual Refilling</h2>
<p>The Token Bucket algorithm approaches rate limiting differently from the Fixed Window and Sliding Window algorithms.</p>
<p>Instead of counting requests, it maintains a <strong>bucket of tokens</strong>.</p>
<p>Each request consumes one token. Tokens are gradually added back to the bucket at a configured refill rate.</p>
<p>For example, this configuration:</p>
<pre><code class="language-javascript">{
    type: 'token',
    capacity: 5,
    refillRate: 1
}
</code></pre>
<p>means:</p>
<ul>
<li><p>The bucket can hold at most <strong>5 tokens</strong>.</p>
</li>
<li><p>Tokens are refilled at <strong>1 token per second</strong>.</p>
</li>
<li><p>Each allowed request consumes <strong>1 token</strong>.</p>
</li>
</ul>
<h3>Initial state</h3>
<p>When a client first uses the limiter, the Lua script initializes a new bucket with the configured capacity. With <code>capacity = 5</code>, the first request sees a bucket containing 5 tokens before consuming one.</p>
<pre><code class="language-text">Bucket capacity = 5

[ ● ● ● ● ● ]
  5 tokens
</code></pre>
<p>The client can therefore make several requests immediately.</p>
<p>After five requests:</p>
<pre><code class="language-text">[           ]
  0 tokens
</code></pre>
<p>The next request is rejected unless tokens have been refilled.</p>
<h3>Gradual refill</h3>
<p>Unlike a fixed window, the bucket doesn't suddenly reset after a particular period.</p>
<p>With:</p>
<pre><code class="language-text">refillRate = 1 token/second
</code></pre>
<p>After the initial tokens have been consumed and the bucket reaches zero, the refill process works like this:</p>
<pre><code class="language-text">0 seconds → 0 tokens
1 second  → 1 token
2 seconds → 2 tokens
3 seconds → 3 tokens
...
</code></pre>
<p>The bucket can never exceed its configured capacity.</p>
<p>So if the capacity is <code>5</code>:</p>
<pre><code class="language-text">tokens = min(capacity, tokens + refilledTokens)
</code></pre>
<p>This allows <strong>controlled bursts</strong> while still limiting the long-term request rate.</p>
<h3>Redis state</h3>
<p>The implementation needs to remember two pieces of information:</p>
<pre><code class="language-text">Token bucket state

&lt;key&gt;
   └── current token amount

&lt;key&gt;:lastRefill
   └── timestamp of the last refill
</code></pre>
<p>The timestamp is necessary because the limiter needs to calculate how much time has passed since the previous refill.</p>
<p>Conceptually:</p>
<pre><code class="language-text">Request
   ↓
Read token count + last refill time
   ↓
Calculate elapsed time
   ↓
Calculate tokens to add
   ↓
Cap tokens at capacity
   ↓
Enough tokens?
   ├── Yes → consume 1 token → allow
   └── No  → reject
</code></pre>
<h3>Why Lua is used here</h3>
<p>Just like the Sliding Window algorithm, the Token Bucket performs several operations that need to work together.</p>
<p>The implementation calculates the refill, updates the token count, updates the timestamp, and decides whether the request is allowed.</p>
<p><mark class="bg-yellow-200 dark:bg-yellow-500/30">These operations are handled by a Redis Lua script so that the state transition happens atomically.</mark></p>
<p>The Node.js side passes the configuration into the Redis command:</p>
<pre><code class="language-javascript">const allowed = await redis.tokenBucket(
    key,
    key + ":lastRefill",
    now,
    refillRate,
    capacity
);
</code></pre>
<p>Here, <code>refillRate</code> represents the number of tokens that should be added <strong>per second</strong>.</p>
<p>The Lua script uses that value together with the elapsed time to determine how many tokens should be restored.</p>
<p>This makes the Token Bucket different from the previous algorithms: instead of asking <strong>"How many requests happened?"</strong>, it asks <strong>"How many tokens are currently available?"</strong></p>
<h2>Verifying <code>refillRate</code> from Node.js to the Redis Lua Script</h2>
<p>The Token Bucket algorithm depends on the refill rate being passed correctly through every layer of the implementation.</p>
<p>In the Express route, the limiter is configured with:</p>
<pre><code class="language-javascript">const limiter = rateLimiter({
    redis,
    type: 'token',
    capacity: 5,
    refillRate: 1
});
</code></pre>
<p>Here:</p>
<pre><code class="language-text">capacity = 5
refillRate = 1 token/second
</code></pre>
<p>The <code>rateLimiter()</code> middleware receives these options and passes them to the Token Bucket implementation:</p>
<pre><code class="language-javascript">allowed = await isAllowedTokenBucket(
    redis,
    key,
    capacity,
    refillRate
);
</code></pre>
<p>The Token Bucket function then passes <code>refillRate</code> to the Redis Lua command:</p>
<pre><code class="language-javascript">const allowed = await redis.tokenBucket(
    key,
    key + ':lastRefill',
    now,
    refillRate,
    capacity
);
</code></pre>
<p>Because the Redis command is configured with:</p>
<pre><code class="language-javascript">redis.defineCommand('tokenBucket', {
    numberOfKeys: 2,
    lua: TokenBucket_Script
});
</code></pre>
<p>the arguments are divided between <code>KEYS</code> and <code>ARGV</code>.</p>
<p>The first two arguments are Redis keys:</p>
<pre><code class="language-text">KEYS[1] → key
KEYS[2] → key:lastRefill
</code></pre>
<p>The remaining arguments become:</p>
<pre><code class="language-text">ARGV[1] → now
ARGV[2] → refillRate
ARGV[3] → capacity
</code></pre>
<p>The Lua script can therefore retrieve the refill rate with:</p>
<pre><code class="language-lua">local refillRate = tonumber(ARGV[2])
</code></pre>
<p>and use it when calculating how many tokens should be restored.</p>
<p>The complete flow is:</p>
<pre><code class="language-text">Express configuration
        |
        | refillRate = 1
        ↓
rateLimiter()
        |
        ↓
isAllowedTokenBucket()
        |
        | refillRate
        ↓
redis.tokenBucket()
        |
        ↓
Redis Lua script
        |
        | ARGV[2]
        ↓
refillRate
</code></pre>
<p>While revisiting the implementation, I specifically traced <code>refillRate</code> through each layer to make sure the configured value was actually reaching the Lua script. The refill rate needs to reach the Lua script and participate in the refill calculation for the configured behavior to be meaningful.</p>
<h2>Testing the Rate Limiter and HTTP 429 Responses</h2>
<p>After implementing the three algorithms, I tested them by sending requests to the Express endpoints.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a9add4bb55da54b763bf637/8ec25222-46da-4ee8-8a22-afa139e89d6c.png" alt="" style="display:block;margin:0 auto" />

<p>The basic test configuration was:</p>
<pre><code class="language-javascript">{
    limit: 5,
    windowSize: 10000
}
</code></pre>
<p>for the Fixed Window and Sliding Window limiters, and:</p>
<pre><code class="language-javascript">{
    capacity: 5,
    refillRate: 1
}
</code></pre>
<p>for the Token Bucket.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a9add4bb55da54b763bf637/9c2cb2d6-0520-4325-9f12-59b4f393286f.png" alt="" style="display:block;margin:0 auto" />

<h3>Testing the request limit</h3>
<p>For example, with the Fixed Window limiter configured for 5 requests per 10 seconds, the first five requests should be accepted:</p>
<pre><code class="language-text">Request 1 → 200
Request 2 → 200
Request 3 → 200
Request 4 → 200
Request 5 → 200
</code></pre>
<p>The sixth request exceeds the configured limit:</p>
<pre><code class="language-text">Request 6 → 429
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/6a9add4bb55da54b763bf637/61c91938-409d-47e5-8d4a-6c43319d06aa.png" alt="" style="display:block;margin:0 auto" />

<p>The middleware returns:</p>
<pre><code class="language-json">{
    "error": "Rate limit exceeded"
}
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/6a9add4bb55da54b763bf637/045bc7e2-bf47-4ae8-bbde-9fc437966eee.png" alt="" style="display:block;margin:0 auto" />

<p>The <code>429</code> status code is important because it tells the client that the request was rejected because the client exceeded the allowed request rate.</p>
<h3>Automated tests</h3>
<p>I also added automated tests using Node.js's built-in test runner.</p>
<p>The tests cover the three algorithms separately:</p>
<pre><code class="language-text">test/
├── fixed-window.test.js
├── sliding-window.test.js
└── token-bucket.test.js
</code></pre>
<p>For Fixed Window and Sliding Window, the tests verify that requests are allowed up to the configured limit, subsequent requests are rejected, and requests become allowed again after the relevant window expires.</p>
<p>For Token Bucket, the tests verify the bucket's initial capacity, rejection after available tokens are consumed, and token availability after refill.</p>
<p>Running:</p>
<pre><code class="language-shell">npm test
</code></pre>
<p>runs the test suite through Node.js's built-in test runner.</p>
<p>I also tested the package from its generated npm tarball rather than importing the source directly. This helped verify that the package's public entry point and installation flow worked as expected.</p>
<p>The combination of automated tests and manual HTTP requests made it possible to verify both the <strong>algorithm behavior</strong> and the <strong>Express middleware behavior</strong>.</p>
<h2>Production Improvements</h2>
<p>The implementation works as a single-node rate limiter, but there are several areas that would need attention before treating it as production-ready.</p>
<h3>Redis configuration</h3>
<p>The current implementation uses a Redis client supplied by the application. In a production environment, the Redis connection should be configurable through environment variables rather than relying on local defaults.</p>
<p>This also makes it easier to use the same rate limiter across different environments.</p>
<h3>Key design</h3>
<p>The rate limiter needs a consistent way to identify clients.</p>
<p>The current implementation defaults to:</p>
<pre><code class="language-javascript">const key = keyGenerator ? keyGenerator(req) : req.ip;
</code></pre>
<p>Using a custom <code>keyGenerator</code> makes the middleware more flexible because an application can choose to rate-limit based on something other than an IP address, such as an authenticated user or API key.</p>
<p>When using IP addresses, proxy configuration also needs to be considered so that the application receives the correct client IP.</p>
<h3>Atomicity</h3>
<p>One of the main lessons from implementing the Sliding Window and Token Bucket algorithms was that <strong>the algorithm itself is only part of the problem</strong>.</p>
<p>The Redis operations that update the rate-limiter state also need to be considered from a concurrency perspective.</p>
<p>Lua scripts allow the required operations to execute atomically inside Redis, which is particularly important when multiple requests arrive at the same time.</p>
<h3>Configuration validation</h3>
<p>The middleware validates configuration when <code>rateLimiter()</code> is created rather than repeatedly validating it for every request.</p>
<p>For example, limits and capacities must be positive integers, while <code>windowSize</code> and <code>refillRate</code> must be positive numbers.</p>
<p>This keeps invalid configuration from reaching the request-processing path.</p>
<h3>Additional improvements</h3>
<p>There are still several areas that could be improved:</p>
<ul>
<li><p>Add rate-limit response headers such as <code>Retry-After</code>.</p>
</li>
<li><p>Add automated concurrency tests.</p>
</li>
<li><p>Add more detailed tests for token refill behavior.</p>
</li>
<li><p>Add TTL handling for Token Bucket state to avoid unnecessary long-lived keys.</p>
</li>
<li><p>Make the Redis connection and failure behavior configurable.</p>
</li>
<li><p>Review the client-key strategy for different deployment environments.</p>
</li>
<li><p>Add more comprehensive validation and error handling.</p>
</li>
</ul>
<h2>Comparing the Three Algorithms and When to Use Each One</h2>
<p>The three algorithms solve the same basic problem controlling request rates but they do it in different ways.</p>
<table>
<thead>
<tr>
<th>Algorithm</th>
<th>Redis state</th>
<th>Burst behavior</th>
<th>Accuracy</th>
<th>Main tradeoff</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Fixed Window</strong></td>
<td>Integer counter</td>
<td>Boundary bursts possible</td>
<td>Basic</td>
<td>Simplest, but coarse boundaries</td>
</tr>
<tr>
<td><strong>Sliding Window</strong></td>
<td>Sorted-set timestamps</td>
<td>More controlled</td>
<td>Higher</td>
<td>More memory and work</td>
</tr>
<tr>
<td><strong>Token Bucket</strong></td>
<td>Token amount + timestamp</td>
<td>Bursts up to capacity</td>
<td>Rate-oriented</td>
<td>Refill semantics must be precise</td>
</tr>
</tbody></table>
<h3>Fixed Window</h3>
<p>Fixed Window is a good choice when simplicity is the priority.</p>
<p>It requires only a counter and an expiration time, making it straightforward to understand and relatively inexpensive to operate.</p>
<p>The tradeoff is the window-boundary problem. Requests can cluster around the boundary between two windows and produce a larger short-term burst than the configured limit suggests.</p>
<h3>Sliding Window</h3>
<p>Sliding Window provides more precise control because it considers the timestamps of requests that actually occurred within the recent window.</p>
<p>It avoids the fixed-window boundary problem, but this comes with additional Redis state and processing.</p>
<p>In this implementation, Redis Sorted Sets and a Lua script are used to maintain that state atomically.</p>
<h3>Token Bucket</h3>
<p>Token Bucket is useful when controlled bursts are desirable.</p>
<p>A client can consume several accumulated tokens quickly, up to the bucket's capacity, while the refill rate controls how quickly tokens become available again.</p>
<p>This makes the algorithm different from simply counting requests within a time window. Its behavior depends heavily on correctly implementing the relationship between <strong>capacity, elapsed time, and refill rate</strong>.</p>
<h3>Which one should you choose?</h3>
<p>There isn't one algorithm that is always the best choice.</p>
<p>A simple API might be well served by <strong>Fixed Window</strong>.</p>
<p>When more precise control over a moving time window is important, <strong>Sliding Window</strong> can be a better fit.</p>
<p>When the system should allow bursts while controlling the longer-term rate, <strong>Token Bucket</strong> is a useful option.</p>
<p>The important part is understanding the behavior and tradeoffs of the algorithm rather than choosing one simply because it is more sophisticated.</p>
<h2>Conclusion and What I Learned</h2>
<p>Building this rate limiter started as an exercise in implementing three different algorithms, but the implementation exposed several practical concerns that are easy to miss when looking at the algorithms only as formulas.</p>
<p>The three approaches store and manage state differently:</p>
<pre><code class="language-text">Fixed Window
→ Counter + expiration

Sliding Window
→ Sorted Set + timestamps + Lua

Token Bucket
→ Tokens + last-refill timestamp + Lua
</code></pre>
<p>The project also highlighted how important it is to understand the connection between application code and the data stored in Redis. Things such as Redis data types, key design, time units, expiration, and atomic operations directly affect whether the rate limiter behaves correctly.</p>
<p>The next step for this project is exploring how the design changes when the application is no longer running as a single Node.js instance.</p>
<p>A distributed setup could look like:</p>
<pre><code class="language-text">                Client
                   |
             Load Balancer
              /     |     \
             /      |      \
        Node.js   Node.js   Node.js
           \        |        /
            \       |       /
             \      |      /
                  Redis
</code></pre>
<p>That introduces new questions around load balancing, shared state, API gateways, and distributed systems.</p>
<p>I have not implemented that architecture yet. It is the next area I want to learn and explore based on this foundation.</p>
]]></content:encoded></item></channel></rss>