Sliding Window Rate Limiter (TypeScript)
Enforce per-key request limits over a sliding time window with cost support, retry hints, remaining quota, and expired-entry cleanup.
Code
export interface RateLimitResult {
allowed: boolean;
remaining: number;
retryAfterMs: number;
resetAt: number;
}
export class SlidingWindowRateLimiter {
private readonly hits = new Map<string, number[]>();
public constructor(
private readonly limit: number,
private readonly windowMs: number,
private readonly now: () => number = Date.now,
) {
if (!Number.isInteger(limit) || limit < 1) {
throw new RangeError("limit must be a positive integer");
}
if (!Number.isFinite(windowMs) || windowMs <= 0) {
throw new RangeError("windowMs must be a positive finite number");
}
if (typeof now !== "function") {
throw new TypeError("now must be a function");
}
}
public consume(key: string, cost = 1): RateLimitResult {
if (typeof key !== "string" || key.length === 0) {
throw new TypeError("key must be a non-empty string");
}
if (!Number.isInteger(cost) || cost < 1 || cost > this.limit) {
throw new RangeError("cost must be an integer between 1 and limit");
}
const timestamp = this.now();
const cutoff = timestamp - this.windowMs;
const timestamps = (this.hits.get(key) ?? []).filter(
(value) => value > cutoff,
);
if (timestamps.length + cost > this.limit) {
const oldest = timestamps[0] ?? timestamp;
this.hits.set(key, timestamps);
return {
allowed: false,
remaining: Math.max(0, this.limit - timestamps.length),
retryAfterMs: Math.max(0, oldest + this.windowMs - timestamp),
resetAt: oldest + this.windowMs,
};
}
for (let index = 0; index < cost; index += 1) {
timestamps.push(timestamp);
}
this.hits.set(key, timestamps);
return {
allowed: true,
remaining: this.limit - timestamps.length,
retryAfterMs: 0,
resetAt: timestamps[0] + this.windowMs,
};
}
public reset(key: string): boolean {
return this.hits.delete(key);
}
public prune(): number {
const cutoff = this.now() - this.windowMs;
let removed = 0;
for (const [key, timestamps] of this.hits) {
const active = timestamps.filter((value) => value > cutoff);
if (active.length === 0) {
this.hits.delete(key);
removed += 1;
} else {
this.hits.set(key, active);
}
}
return removed;
}
}
let now = 0;
const limiter = new SlidingWindowRateLimiter(3, 1000, () => now);
for (let index = 0; index < 3; index += 1) {
console.log(limiter.consume("user:1"));
}
console.log(limiter.consume("user:1"));
now = 600;
console.log(limiter.consume("user:1", 2));
now = 1001;
console.log(limiter.consume("user:1"));
console.log(limiter.consume("user:2", 2));
now = 3000;
console.log(limiter.prune());Report a problem with this exampleOpens a pre-filled issue on GitHub.