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
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.
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.
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.
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
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
- Zero runtime dependencies. The only development dependency is TypeScript itself, so there is nothing underneath it to audit or keep updated.
- Time is injectable. Every limiter accepts a clock, so the tests move time forward by hand. No test ever sleeps, and every result is deterministic.
- Exact retry times. When a request is rejected, the token bucket and the log say exactly how long to wait, and the counter gives an estimate, ready to send as a
Retry-Afterheader. - Strict TypeScript. Strict compiler settings, including
noUncheckedIndexedAccess, so edge cases like missing values are handled rather than assumed away.
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