● Case study · Backend algorithms

Saying “not yet”, fairly.

ts-rate-limiter is a small TypeScript library that keeps an API responsive by limiting how often each user can call it, with three strategies to choose from and no dependencies.

Rodz·4 min read·TypeScript · Node.js
100 AT 0:59. 100 AT 1:00. 200 IN 2 S. A “100 per minute” limit, fooled. 0:59 1:00 WINDOW RESETS

Every public API eventually meets a client that sends too much: a buggy script stuck in a loop, an aggressive scraper, a sudden spike. Without limits, one noisy caller slows things down for everyone else.

Rate limiting is the answer. Each user, IP address or API key gets a budget, and requests beyond it receive a polite “try again in N seconds” instead of a slow or failing service. ts-rate-limiter packages that idea for Node.js and TypeScript.

01The trap in the obvious solution

The simplest limiter counts requests per calendar minute and resets the counter when the minute changes. It sounds right, and it has a hole: a client can send 100 requests at 0:59 and 100 more at 1:00. Both minutes respect the limit, yet the server just took 200 requests in two seconds.

Both minutes respect the limit, yet the server takes 200 requests in two seconds.

That is why this library doesn't use a fixed window at all. It offers three strategies that close the hole in different ways, and lets you pick based on what you're protecting.

02Three strategies, one interface

Bursts are fine

Token bucket

Allows a short burst up to a capacity, then a steady refill rate. Good for APIs where a quick flurry is normal but sustained floods are not. Constant memory.

Strict quota

Sliding window log

Exactly N requests in any window, no matter where it starts. Ideal for small, strict limits such as login attempts or password resets. Memory grows with the limit.

High volume

Sliding window counter

Approximately N per window with constant memory, and no burst at the boundary. A good fit for high-volume limits where a small estimation error is acceptable.

All three answer the same question in the same shape: is this request allowed, how much budget remains, and if not, how long until it would succeed. To see the difference, the project ships a demo that sends identical traffic to each one: eight requests at once, a quiet pause, then one request every 100 milliseconds.

FIG. 02 — SAME TRAFFIC, THREE ANSWERS LIMIT: 5 PER SECOND REQUEST TIME (MS) ACCEPTED 0 ms ×8 700 800 900 1000 1100 1200 1300 1400 1500 1600 1700 1800 token bucket cap 5, 5/s 14/20 sliding log 5 / 1 s 10/20 sliding counter 5 / 1 s 9/20 allowed rejected with a retry time
Real output of the project's demo (npm run demo). The token bucket refills as time passes, the log is strict about the last second, and the counter estimates.

All three let the first five requests of the burst through and turn the rest away. After the pause they diverge: the token bucket has refilled and accepts most of the steady traffic, the log counts exactly what happened in the last second, and the counter's estimate is a little more cautious. None of them is “best”; they are different promises.

03One budget per user

FIG. 03 — A REQUEST'S PATH request user: ana by key KeyedRateLimiter bea → bucket · 3 left ana → bucket · 0 left caio → idle, pruned one limiter per key, created on first use allowed → your app rejected 429 Too Many Requests Retry-After: 1 check() returns { allowed, remaining, retryAfterMs }, ready to become response headers.
Illustrative users. Each key gets its own limiter, so one noisy client exhausts only its own budget.

In a real API you rarely want one global limit; you want one per user, per IP or per API key. A keyed wrapper creates a limiter the first time it sees a key and can evict keys that have gone quiet, so memory stays bounded. The factory even receives the key, so a free plan and a paid plan can get different limits.

04Decisions I made, and why

05Known limits

It runs in a single process by design: limits live in that process's memory. With several servers behind a load balancer, each would keep its own count. The same algorithms map naturally onto Redis for a shared, distributed limiter, which is the obvious next step.

Try it or read the code

Open source under the MIT licence. Needs Node 22.18 or newer; the demo above runs with:

git clone https://github.com/Rodzman/ts-rate-limiter
cd ts-rate-limiter && npm install && npm run demo
TypeScriptNode.js 22zero dependenciesnode:testtoken bucketsliding window