I build a project called injection-arena, a small playground where people send prompts at an endpoint to try and break a guarded model. The moment an endpoint accepts untrusted attempts from strangers, one question shows up fast: how do I stop a single client from hammering it thousands of times a second? The answer is rate limiting, and the simplest version that actually works is the fixed window. In this tutorial I want to build one from scratch in TypeScript, then be honest about where it fits and where it does not.
The idea behind a fixed window
A fixed-window limiter cuts time into equal slices, say 60 seconds, and counts requests per client inside the current slice. If a client is allowed 10 requests per minute, you keep a counter for that client. Each request increments it. When the counter goes over 10, you reject until the window rolls over to the next minute, at which point the count resets to zero.
That is the whole model. A key, a count, and an expiry timestamp.
A minimal in-memory limiter
Here is a correct, self-contained implementation. No dependencies.
type Bucket = { count: number; resetAt: number };
class FixedWindowLimiter {
private store = new Map<string, Bucket>();
constructor(
private readonly limit: number,
private readonly windowMs: number,
) {}
check(key: string): { allowed: boolean; remaining: number; resetAt: number } {
const now = Date.now();
const bucket = this.store.get(key);
if (!bucket || now >= bucket.resetAt) {
const resetAt = now + this.windowMs;
this.store.set(key, { count: 1, resetAt });
return { allowed: true, remaining: this.limit - 1, resetAt };
}
if (bucket.count >= this.limit) {
return { allowed: false, remaining: 0, resetAt: bucket.resetAt };
}
bucket.count += 1;
return {
allowed: true,
remaining: this.limit - bucket.count,
resetAt: bucket.resetAt,
};
}
}
Two things worth noticing. First, the window is lazy: I do not run a timer to reset counters. I just check whether now has passed resetAt and start fresh if it has. Second, check both reads and mutates in one call, so there is no gap between deciding and recording.
Wiring it into an API route
The attempt endpoint in injection-arena needs this in front of its handler. Here is the pattern as an Express-style middleware, keyed by client IP.
const limiter = new FixedWindowLimiter(10, 60_000); // 10 per minute
function rateLimit(req: Request, res: Response, next: NextFunction) {
const key = req.ip ?? "unknown";
const result = limiter.check(key);
res.setHeader("X-RateLimit-Limit", "10");
res.setHeader("X-RateLimit-Remaining", String(result.remaining));
res.setHeader("X-RateLimit-Reset", String(Math.ceil(result.resetAt / 1000)));
if (!result.allowed) {
const retryAfter = Math.ceil((result.resetAt - Date.now()) / 1000);
res.setHeader("Retry-After", String(retryAfter));
return res.status(429).json({ error: "Too many requests" });
}
next();
}
app.post("/api/attempt", rateLimit, attemptHandler);
Returning 429 with a Retry-After header is the polite, standards-friendly way to tell a client when to come back. Do not skip those headers. Good clients back off automatically when you send them.
Why fixed window, and what it costs
The appeal is that it is trivial to reason about and cheap to run: one integer and one timestamp per client. The famous weakness is the boundary burst. Because the counter snaps back to zero at the window edge, a client can send 10 requests in the last second of one window and 10 more in the first second of the next. That is 20 requests in about two seconds while never technically breaking a 10-per-minute rule. For an abuse-prevention limiter guarding an expensive operation, that burst can matter.
That leads people to two alternatives.
Sliding window smooths the boundary. Instead of a hard reset, it weighs the previous window's count against how far you are into the current one, or it keeps a log of timestamps and counts only those within the trailing 60 seconds. It removes the double-burst problem at the cost of more memory (a log per key) or more arithmetic (the weighted approximation). It is the natural upgrade when the fixed-window burst is unacceptable.
Token bucket models a different shape of traffic. Tokens refill at a steady rate up to a cap; each request spends one. It allows short bursts up to the bucket size while enforcing a long-run average rate. This is what you want when bursts are legitimate and you only care about sustained throughput, like an API that lets users batch a few calls together but not run flat out forever.
My rule of thumb: fixed window for simple per-endpoint protection where a small edge burst is harmless, token bucket when you want to permit bursts on purpose, sliding window when you need strict fairness across the boundary.
The honest caveat: in-memory does not survive multiple instances
The limiter above stores counts in a Map inside one Node process. That is completely fine when you run a single instance, which is exactly the case for injection-arena right now. It is fast, it needs no infrastructure, and it loses nothing.
But the moment you run two or more instances behind a load balancer, each process keeps its own Map. A client allowed 10 per minute now effectively gets 10 per minute per instance, so with three instances they get 30. The counters never see each other. The same problem hits you on any serverless or autoscaling platform where instances come and go, since a restart wipes the map entirely.
When you cross that line, move the state to a shared store. Redis is the standard answer because it gives you atomic operations. A fixed window in Redis is roughly:
async function checkRedis(key: string, limit: number, windowSec: number) {
const redisKey = `rl:${key}:${Math.floor(Date.now() / 1000 / windowSec)}`;
const count = await redis.incr(redisKey);
if (count === 1) {
await redis.expire(redisKey, windowSec);
}
return { allowed: count <= limit, remaining: Math.max(0, limit - count) };
}
The window is encoded into the key itself, so it resets naturally when the time bucket changes, and INCR is atomic across every instance. That is the same fixed-window algorithm, just with shared state.
Where to start
For most small services, start in memory. It is the least code, the least infrastructure, and it teaches you the shape of the problem. Reach for Redis when you genuinely run more than one instance or need counts to survive restarts, not before. Premature distribution is its own kind of debt.
If you want to see a fixed-window limiter guarding a real, adversarial endpoint, the attempt route in injection-arena is a good place to poke around: github.com/AgentPostmortem/injection-arena. Clone it, hit the endpoint in a loop, and watch the 429s show up.
Top comments (0)