Skip to content

@studnicky/sliding-window-limiter

Sliding-window rate limiter — exact timestamp-log or approximate blended-counter algorithm. Independently usable and composable.

Install

bash
pnpm add @studnicky/sliding-window-limiter

Requires @studnicky:registry=https://npm.pkg.github.com in .npmrc.

Usage

Rate-limits by counting requests within a rolling time window rather than a continuously-refilling bucket — the single most common rate-limit ask. Two algorithms, selected via algorithm:

  • 'log' — exact. A bounded queue of admitted request timestamps (capacity = limit) is pruned of stale entries on every consume(). No false positives or negatives, O(limit) memory.
  • 'counter' — approximate, O(1) space. Blends the current and previous fixed window's counts by the elapsed fraction of the current window, smoothing the boundary discontinuity a naive fixed-window counter would show.
ts
import { SlidingWindowExhaustedError } from '../src/SlidingWindowExhaustedError.js';
import { SlidingWindowLimiter } from '../src/SlidingWindowLimiter.js';

class TracedLimiter extends SlidingWindowLimiter {
  readonly allowed: number[] = [];
  readonly rejected: number[] = [];
  readonly rolls: number[] = [];
  constructor(options: SlidingWindowLimiterOptionsInterface) { super(options); }
  protected override onAllow(count: number): void {
    console.log(`[sliding-window-limiter] onAllow — effective count=${count}`);
    this.allowed.push(count);
  }
  protected override onReject(count: number): void {
    console.log(`[sliding-window-limiter] onReject — effective count=${count}`);
    this.rejected.push(count);
  }
  protected override onWindowRoll(): void {
    console.log('[sliding-window-limiter] onWindowRoll — window boundary advanced');
    this.rolls.push(1);
  }
}

// A deterministic (virtual) clock — no real timers, fully reproducible.
let time = 0;
class Clock {
  static now(): number { const result = time; return result; }
}

// --- Scenario 1: 'log' algorithm — exact timestamp pruning ---
console.log('\n--- log algorithm ---');
const logLimiter = new TracedLimiter({ 'algorithm': 'log', 'clock': Clock.now, 'limit': 2, 'windowMs': 100 });
logLimiter.consume(); // t=0, admitted (1/2)
logLimiter.consume(); // t=0, admitted (2/2)
try {
  logLimiter.consume(); // t=0, rejected — at limit
} catch (err) {
  if (err instanceof SlidingWindowExhaustedError) {
    console.log('  caught SlidingWindowExhaustedError as expected');
  }
}
time = 101; // fully past the window
logLimiter.consume(); // succeeds — stale entries pruned first

// --- Scenario 2: 'counter' algorithm — blended fixed-window estimate ---
console.log('\n--- counter algorithm ---');
time = 0;
const counterLimiter = new TracedLimiter({ 'algorithm': 'counter', 'clock': Clock.now, 'limit': 2, 'windowMs': 100 });
counterLimiter.consume(); // window 0, admitted (1/2)
counterLimiter.consume(); // window 0, admitted (2/2)
try {
  counterLimiter.consume(); // window 0, rejected — at limit
} catch (err) {
  if (err instanceof SlidingWindowExhaustedError) {
    console.log('  caught SlidingWindowExhaustedError as expected');
  }
}
time = 199; // near the far edge of the next window — blend has decayed
counterLimiter.consume(); // succeeds — decayed blended estimate is under the limit

// --- Scenario 3: waitForToken polls until admission succeeds ---
console.log('\n--- waitForToken ---');
const waiter = new TracedLimiter({ 'algorithm': 'counter', 'limit': 1, 'windowMs': 30 });
waiter.consume(); // exhaust the single slot
await waiter.waitForToken(); // resolves once the blended estimate decays below the limit
console.log('  waitForToken resolved');

Structural fit with @studnicky/keyed-rate-limiter

consume(tokens?: number): void and waitForToken(options?: { signal?: AbortSignal; tokens?: number }): Promise<void> structurally match the rate-limiter strategy seam @studnicky/keyed-rate-limiter expects from a single-instance limiter it wraps per-key — the same method names and parameter shapes TokenBucket already exposes. Neither package imports the other; the fit is TypeScript structural typing only.

tokens is accepted on both methods purely for that structural match. Sliding-window rate limiting is one-request-at-a-time by nature, so the value passed is otherwise ignored — every consume() call is treated as exactly one admitted request regardless of tokens.

Try it

The demo subclasses SlidingWindowLimiter and overrides onAllow, onReject, and onWindowRoll. It walks both algorithms through admission up to the limit, a rejection at the limit, and admission again once the window (or its blended estimate) clears — then demonstrates waitForToken polling until capacity frees up.

SlidingWindowLimiter — both algorithms and lifecycle hooks
/** observedSlidingWindowLimiter — trace lifecycle hooks across both algorithms. Run: npx tsx examples/observedSlidingWindowLimiter.ts */

import assert from 'node:assert/strict';

import type { SlidingWindowLimiterOptionsInterface } from '../src/interfaces/SlidingWindowLimiterOptionsInterface.js';

// #region usage
import { SlidingWindowExhaustedError } from '../src/SlidingWindowExhaustedError.js';
import { SlidingWindowLimiter } from '../src/SlidingWindowLimiter.js';

class TracedLimiter extends SlidingWindowLimiter {
  readonly allowed: number[] = [];
  readonly rejected: number[] = [];
  readonly rolls: number[] = [];
  constructor(options: SlidingWindowLimiterOptionsInterface) { super(options); }
  protected override onAllow(count: number): void {
    console.log(`[sliding-window-limiter] onAllow — effective count=${count}`);
    this.allowed.push(count);
  }
  protected override onReject(count: number): void {
    console.log(`[sliding-window-limiter] onReject — effective count=${count}`);
    this.rejected.push(count);
  }
  protected override onWindowRoll(): void {
    console.log('[sliding-window-limiter] onWindowRoll — window boundary advanced');
    this.rolls.push(1);
  }
}

// A deterministic (virtual) clock — no real timers, fully reproducible.
let time = 0;
class Clock {
  static now(): number { const result = time; return result; }
}

// --- Scenario 1: 'log' algorithm — exact timestamp pruning ---
console.log('\n--- log algorithm ---');
const logLimiter = new TracedLimiter({ 'algorithm': 'log', 'clock': Clock.now, 'limit': 2, 'windowMs': 100 });
logLimiter.consume(); // t=0, admitted (1/2)
logLimiter.consume(); // t=0, admitted (2/2)
try {
  logLimiter.consume(); // t=0, rejected — at limit
} catch (err) {
  if (err instanceof SlidingWindowExhaustedError) {
    console.log('  caught SlidingWindowExhaustedError as expected');
  }
}
time = 101; // fully past the window
logLimiter.consume(); // succeeds — stale entries pruned first

// --- Scenario 2: 'counter' algorithm — blended fixed-window estimate ---
console.log('\n--- counter algorithm ---');
time = 0;
const counterLimiter = new TracedLimiter({ 'algorithm': 'counter', 'clock': Clock.now, 'limit': 2, 'windowMs': 100 });
counterLimiter.consume(); // window 0, admitted (1/2)
counterLimiter.consume(); // window 0, admitted (2/2)
try {
  counterLimiter.consume(); // window 0, rejected — at limit
} catch (err) {
  if (err instanceof SlidingWindowExhaustedError) {
    console.log('  caught SlidingWindowExhaustedError as expected');
  }
}
time = 199; // near the far edge of the next window — blend has decayed
counterLimiter.consume(); // succeeds — decayed blended estimate is under the limit

// --- Scenario 3: waitForToken polls until admission succeeds ---
console.log('\n--- waitForToken ---');
const waiter = new TracedLimiter({ 'algorithm': 'counter', 'limit': 1, 'windowMs': 30 });
waiter.consume(); // exhaust the single slot
await waiter.waitForToken(); // resolves once the blended estimate decays below the limit
console.log('  waitForToken resolved');
// #endregion usage

assert.deepStrictEqual(logLimiter.allowed, [1, 2, 1], 'log: admitted counts 1, 2, then 1 after pruning');
assert.deepStrictEqual(logLimiter.rejected, [2], 'log: rejected once at the limit');
assert.ok(logLimiter.rolls.length >= 1, 'log: window rolled at least once when pruning stale entries');

assert.strictEqual(counterLimiter.allowed.length, 3, 'counter: three admissions total');
assert.deepStrictEqual(counterLimiter.rejected, [2], 'counter: rejected once at the limit');
assert.ok(counterLimiter.rolls.length >= 1, 'counter: window rolled at least once');

assert.ok(waiter.allowed.length >= 2, 'waiter: initial consume + eventual waitForToken admission both recorded');

console.log('\nobservedSlidingWindowLimiter: all assertions passed');
Output
Press Execute to run this example against the real library.

Observability hooks

Subclass SlidingWindowLimiter and override protected hooks to add logging, metrics, or tracing without coupling the core to any observability library.

HookWhen it firesArgs
onAllow(count)After a request is admittedcount: number — effective window count post-admission
onReject(count)Before throwing, when a request would exceed limitcount: number — effective window count pre-admission
onWindowRoll()When the window boundary advances — 'log': stale entries pruned; 'counter': fixed-window index changed

The base class never calls any logger or metrics library. All hooks are no-ops by default.

API

ExportTypeDescription
SlidingWindowLimiterclassSliding-window rate limiter ('log' or 'counter')
SlidingWindowLimiterBuilderclassFluent builder for SlidingWindowLimiter
SlidingWindowExhaustedErrorclassThrown by consume() when admission would exceed limit
SlidingWindowLimiterConfigErrorclassThrown by create()/build() on invalid configuration
SlidingWindowLimiterErrorclassPackage-level abstract error ancestor
SlidingWindowLimiterOptionsInterfacetype{ limit, windowMs, algorithm: 'log' | 'counter', clock? }

SlidingWindowLimiter

MemberSignatureDescription
consume(tokens?: number) => voidAdmits one request; throws SlidingWindowExhaustedError if it would exceed limit
waitForToken(options?: { signal?, tokens? }) => Promise<void>Polls until consume() would succeed, then consumes

Source on GitHub