Sliding Window Rate Limiter
An in-memory sliding window rate limiter with an injectable clock, tested deterministically across window edges and bursts with exact verdicts.
Project
node-rate-limiter-memory
Entry point: limiter.mjs
Install
node --versionRun
node limiter.mjsTest
node --test limiter.test.mjsExpected output
The demo drives ten requests at t=0 against a limit of three, printing true for the first three and false for the rest.Dependencies
- node >=20
What it does
A sliding window rate limiter with an injectable clock, so window expiry and burst behavior are tested deterministically without sleeping.
export class SlidingWindowLimiter {
constructor({ limit, windowMs, now = () => Date.now() } = {}) {
this.limit = limit;
this.windowMs = windowMs;
this.now = now;
this.hits = new Map();
}
check(key = 'default') {
const t = this.now();
const bucket = (this.hits.get(key) ?? []).filter((ts) => t - ts < this.windowMs);
const allowed = bucket.length < this.limit;
if (allowed) bucket.push(t);
this.hits.set(key, bucket);
return allowed;
}
remaining(key = 'default') {
const t = this.now();
const bucket = (this.hits.get(key) ?? []).filter((ts) => t - ts < this.windowMs);
return Math.max(0, this.limit - bucket.length);
}
}
let clock = 0;
const limiter = new SlidingWindowLimiter({ limit: 3, windowMs: 1000, now: () => clock });
const verdicts = [];
for (let i = 0; i < 10; i++) verdicts.push(limiter.check());
console.log(verdicts.join(','));
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { SlidingWindowLimiter } from './limiter.mjs';
function makeLimiter(limit, windowMs) {
let clock = 0;
return { clock: () => (clock += 0), limiter: new SlidingWindowLimiter({ limit, windowMs, now: () => clock }), set: (v) => { clock = v; } };
}
test('admits up to limit then rejects within the window', () => {
const { limiter, set } = makeLimiter(3, 1000);
set(0);
assert.equal(limiter.check(), true);
assert.equal(limiter.check(), true);
assert.equal(limiter.check(), true);
assert.equal(limiter.check(), false);
});
test('entries expire as the window slides', () => {
const { limiter, set } = makeLimiter(2, 1000);
set(100);
limiter.check();
set(200);
limiter.check();
assert.equal(limiter.check(), false);
set(1100);
assert.equal(limiter.check(), true);
});
test('keys are isolated from each other', () => {
const { limiter, set } = makeLimiter(1, 1000);
set(0);
assert.equal(limiter.check('a'), true);
assert.equal(limiter.check('a'), false);
assert.equal(limiter.check('b'), true);
});
test('remaining reflects capacity after partial use', () => {
const { limiter, set } = makeLimiter(5, 1000);
set(0);
limiter.check();
limiter.check();
assert.equal(limiter.remaining(), 3);
});
Run and verify
node --test limiter.test.mjs
node limiter.mjs
Run the tests for expiry and key isolation, then the demo that drains a three-request budget at t=0.
Expected Output
The test run should print four passing tests for admission, window sliding, key isolation, and remaining capacity — all deterministic because the clock is injected rather than slept on. The demo’s console.log output should print ten verdicts joined by commas; with the limit set to three at a frozen t=0, the Output: is:
true,true,true,false,false,false,false,false,false,false
The first three requests drain the budget, the remaining seven are rejected inside the same window, and advancing the injected clock past windowMs would start admitting again as old entries slide out of the bucket.
Limitations
State is per process, so multiple replicas each enforce the full limit; cross-process strictness needs a shared store.