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, SlidingWindowLimiter } from '../src/index.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;

// --- Scenario 1: 'log' algorithm — exact timestamp pruning ---
console.log('\n--- log algorithm ---');
const logLimiter = new TracedLimiter({ 'algorithm': 'log', 'clock': (): number => { return time; }, '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 (error) {
  if (error 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': (): number => { return time; }, '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 (error) {
  if (error 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.

Loading example…

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')
SlidingWindowExhaustedErrorclassThrown by consume() when admission would exceed limit
SlidingWindowLimiterConfigErrorclassThrown by create() on invalid configuration
SlidingWindowLimiterErrorclassPackage-level abstract error ancestor

SlidingWindowLimiter

MemberSignatureDescription
createstatic create(options: SlidingWindowLimiterOptionsInterface) => SlidingWindowLimiterConstructs a validated limiter; import the contract from @studnicky/sliding-window-limiter/interfaces
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

Entities

@studnicky/sliding-window-limiter/entities exports the limiter option schema.

typescript
import { SlidingWindowLimiterOptionsEntity } from '@studnicky/sliding-window-limiter/entities';

Interfaces

@studnicky/sliding-window-limiter/interfaces exports the limiter option contract.

typescript
import type { SlidingWindowLimiterOptionsInterface } from '@studnicky/sliding-window-limiter/interfaces';

Exports

SymbolPurposeImport path
SlidingWindowLimiterEnforces an exact or approximate sliding-window limit.@studnicky/sliding-window-limiter
SlidingWindowExhaustedErrorSignals that a request exceeds the available window capacity.@studnicky/sliding-window-limiter
SlidingWindowLimiterConfigErrorRepresents invalid limiter configuration.@studnicky/sliding-window-limiter
SlidingWindowLimiterErrorBase error for sliding-window-limiter failures.@studnicky/sliding-window-limiter

Source on GitHub