Build a Sliding Window Rate Limiter
Sliding Window Rate Limiter — a complete walkthrough of precise rate limiting, covering window counter, timestamp trim, boundary check, and testing.
Project goal
Implement precise rate limiting that solves counting requests in a sliding window without storing every timestamp, so the limit is exact and memory is bounded, with each component independently testable.
Final features
- Window counter
- Timestamp trim
- Boundary check
- Distributed sync
- Rejection responder
Architecture
The system is organized around window counter, timestamp trim, boundary check, and distributed sync ties them together. Data flows as a sorted list of request timestamps within the window, trimmed at the head as the window slides forward.
Prerequisites
- Node.js 20 LTS or later
- Familiarity with precise rate limiting fundamentals
- A local environment to run the test suite
Dependencies
- redis ^4.7.0 — Shared window state
- fastify ^5.0.0 — Limit API
Directory structure
rate_limiter_sliding_window/
├── src/index.ts
├── src/window-counter.ts
├── src/timestamp-trim.ts
├── tests/rate_limiter_sliding_window.test.ts
└── package.json
Phases
1. Window counter
Build the window counter that anchors precise rate limiting and defines the core data shape.
// Window counter: precise rate limiting core
export function windowcounter(input: WindowcounterInput): WindowcounterResult {
// Build the window counter that anchors precise rate limiting and defines the core data shape.
return processWindowcounter(input);
}
Expected behavior: After this phase the window counter works in isolation and is covered by a unit test.
2. Timestamp trim
Add the timestamp trim so precise rate limiting can counting requests in a correctly.
// Timestamp trim — handles precise rate limiting Add the timestamp trim so
export class Timestamptrim {
constructor(private opts: TimestamptrimOptions) {}
async run(ctx: Context): Promise<TimestamptrimOutput> {
return this.execute(ctx);
}
}
Expected behavior: After this phase the timestamp trim works in isolation and is covered by a unit test.
3. Boundary check
Wire the boundary check to handle a burst exactly at without losing data.
// Boundary check: precise rate limiting stage
export const boundarycheck = (next: Next) => async (req: Request) => {
const out = await transform(req);
return next(out);
};
Expected behavior: After this phase the boundary check works in isolation and is covered by a unit test.
4. Distributed sync
Integrate the distributed sync to make precise rate limiting observable and testable end to end.
// Distributed sync for precise rate limiting
export async function distributedsync(stream: AsyncIterable<Chunk>): Promise<Aggregate> {
let acc = init();
for await (const c of stream) acc = merge(acc, c);
return acc;
}
Expected behavior: After this phase the distributed sync works in isolation and is covered by a unit test.
5. Rejection responder
Finalize the rejection responder so the precise rate limiting system degrades gracefully under overload.
// Rejection responder: precise rate limiting finalization
export function rejectionresponder(state: State): Result {
validate(state);
return finalize(state);
}
Expected behavior: After this phase the rejection responder works in isolation and is covered by a unit test.
Testing
Each phase has a focused test: window counter, timestamp trim, boundary check are exercised in isolation, then an integration test drives the full precise rate limiting pipeline and asserts the a burst exactly at case is handled.
Troubleshooting
A burst exactly at the window boundary that the counter admits twice if the trim runs after the check
Reproduce with a minimal fixture, then add a guard test so the precise rate limiting system rejects the condition loudly.
Performance regresses under load
Profile with the sampling analyzer, locate the hot path, and add a targeted benchmark to lock the regression.
Deployment
Run two instances behind a load balancer, share state in Redis, and set resource limits so overload surfaces as backpressure rather than failure.
Known limitations
- Single-region; multi-region replication is out of scope.
- The window counter is in-process and not horizontally shared by default.
- No auth layer; assume deployment behind an edge that enforces identity.
Project overview
This project builds a precise rate limiting system whose reason for existing is counting requests in a sliding window without storing every timestamp, so the limit is exact and memory is bounded. The temptation in precise rate limiting is to bolt the interesting part onto a working prototype and discover the hard cases in production; this build inverts that order and treats a burst exactly at the window boundary that the counter admits twice if the trim runs after the check as the first thing designed, not the last.
You will implement Window counter, Timestamp trim, Boundary check, Distributed sync, and Rejection responder, each as a module with one responsibility and a test that proves it in isolation. The precise rate limiting pipeline is then the wiring that composes them, and the integration test is where the composition is shown to survive a burst exactly at the window boundary that the counter admits twice if the trim runs after the check.
The walkthrough is written for a developer who has used a precise rate limiting system but has not had to make one honest. It favors explicit data shapes over clever abstractions, and it names the failure each design choice prevents rather than presenting the choice as a style preference.
Each phase ends with something observable — a window counter that returns the right shape, a timestamp trim that rejects bad input, an integration test that goes green — so you can stop at any point and still have a precise rate limiting system that works for the cases covered so far.
What you will build
The finished precise rate limiting system exposes five capabilities, each with a focused test:
- Window counter
- Timestamp trim
- Boundary check
- Distributed sync
- Rejection responder
Window counter is the foundation that every later stage assumes; Timestamp trim is where precise rate limiting correctness lives; Boundary check handles a burst exactly at the window boundary that the counter admits twice if the trim runs after the check; Distributed sync makes the system observable; and Rejection responder is the graceful-degradation layer that keeps overload from becoming corruption.
The build keeps the precise rate limiting logic in ordinary modules you can read in one sitting, and pulls in dependencies only for the parts that are genuinely tedious to hand-roll. That keeps the diff between the tutorial and a real codebase small, and keeps the failure modes visible in code rather than hidden behind a framework.
Architecture and data model
The system is organized around Window counter, Timestamp trim, and Boundary check, with Distributed sync tying them together. Data flows as a sorted list of request timestamps within the window, trimmed at the head as the window slides forward.
Each component is a module with a pure interface where practical: the same input produces the same output, and side effects are pushed to the edges. That discipline is what makes the phases independently testable, because a test for Boundary check does not need to stand up Window counter and Timestamp trim to exercise the interesting branch.
The data model is deliberately explicit. A sorted list of request timestamps within the window, trimmed at the head as the window slides forward Keeping the shape written down — in a schema, a type, or a fixture — means a change to the shape is a visible diff rather than a silent runtime shift, and it means the integration test can assert against a concrete shape instead of guessing.
Configuration and code are kept apart: the configuration says what precise rate limiting should do, the modules say how. A reviewer can change a threshold or a policy without reading the implementation, which keeps the risky changes small and lets the diff stand as the review.
The failure path through Boundary check is designed before the happy path is finished, because a burst exactly at the window boundary that the counter admits twice if the trim runs after the check is the case that distinguishes a precise rate limiting system from a demo. Every component declares what it does when its inputs are wrong, and the integration test asserts each of those declarations.
Prerequisites and dependencies
Have these ready before you install anything, so the precise rate limiting setup is not blocked by a missing tool:
- Node.js 20 LTS or later
- Familiarity with precise rate limiting fundamentals
- A local environment to run the test suite
The runtime dependencies are chosen to keep the precise rate limiting datapath legible:
redis(^4.7.0) — Shared window statefastify(^5.0.0) — Limit API
# setup for rate-limiter-sliding-window
pnpm init
pnpm add redis fastify
pnpm add -D typescript tsx vitest
Versions are pinned to a major range so the precise rate limiting install is reproducible; commit the lockfile. The window counter reads its operational parameters from the environment and validates them at startup, so a missing value fails the boot rather than the first precise rate limiting request.
Directory structure
Keep each precise rate limiting component in its own file so it can be tested without starting the others:
rate_limiter_sliding_window/
├── src/index.ts
├── src/window-counter.ts
├── src/timestamp-trim.ts
├── tests/rate_limiter_sliding_window.test.ts
└── package.json
Phase 1: Window counter
Build the window counter that anchors precise rate limiting and defines the core data shape.
// Window counter: precise rate limiting core
export function windowcounter(input: WindowcounterInput): WindowcounterResult {
// Build the window counter that anchors precise rate limiting and defines the core data shape.
return processWindowcounter(input);
}
After this phase the window counter works in isolation and is covered by a unit test. The window counter defines the data shape that every later stage assumes, so getting it right first means the later phases compile against a stable contract.
Treat the window counter as the single source of truth for the precise rate limiting data shape. Validate inputs at the boundary and reject early, so a malformed value never reaches Timestamp trim or Boundary check where its effect would be harder to localize.
Phase 2: Timestamp trim
Add the timestamp trim so precise rate limiting can counting requests in a correctly.
// Timestamp trim — handles precise rate limiting Add the timestamp trim so
export class Timestamptrim {
constructor(private opts: TimestamptrimOptions) {}
async run(ctx: Context): Promise<TimestamptrimOutput> {
return this.execute(ctx);
}
}
After this phase the timestamp trim works in isolation and is covered by a unit test. This is where precise rate limiting correctness lives: the timestamp trim is what makes counting requests in a sliding window without storing every timestamp, so the limit is exact and memory is bounded true, and its test is the proof.
Keep the timestamp trim pure where you can. A function that takes an input and returns an output is trivial to test and impossible to mock wrong; a class with hidden state is the opposite. Push side effects to the caller and keep the timestamp trim about the transformation.
Phase 3: Boundary check
Wire the boundary check to handle a burst exactly at without losing data.
// Boundary check: precise rate limiting stage
export const boundarycheck = (next: Next) => async (req: Request) => {
const out = await transform(req);
return next(out);
};
After this phase the boundary check works in isolation and is covered by a unit test. The boundary check is the difference between a precise rate limiting system that works on the happy path and one that survives a burst exactly at the window boundary that the counter admits twice if the trim runs after the check.
Design the failure before the success. Decide what boundary check does when a burst exactly at the window boundary that the counter admits twice if the trim runs after the check: reject, retry, shed, or degrade — and write that test first. A precise rate limiting system whose failure behavior is unspecified will pick one at random under pressure, and it is usually the wrong one.
Phase 4: Distributed sync
Integrate the distributed sync to make precise rate limiting observable and testable end to end.
// Distributed sync for precise rate limiting
export async function distributedsync(stream: AsyncIterable<Chunk>): Promise<Aggregate> {
let acc = init();
for await (const c of stream) acc = merge(acc, c);
return acc;
}
After this phase the distributed sync works in isolation and is covered by a unit test. Observability is not a finishing touch; it is how you know the precise rate limiting system is doing what it claims in production.
Emit one structured event per meaningful action, with enough context to localize a failure without a second round-trip. The distributed sync is what turns “something is slow” into “the timestamp trim is slow for inputs matching this shape,” which is the difference between an alert and an investigation.
Phase 5: Rejection responder
Finalize the rejection responder so the precise rate limiting system degrades gracefully under overload.
// Rejection responder: precise rate limiting finalization
export function rejectionresponder(state: State): Result {
validate(state);
return finalize(state);
}
After this phase the rejection responder works in isolation and is covered by a unit test. The rejection responder is what keeps overload from becoming corruption: when the precise rate limiting system is asked for more than it can do, it degrades in a defined way instead of failing in an undefined one.
Pick a degradation strategy for rejection responder — shed, queue, or backpressure — and make it explicit in code and in a test. Leaving the behavior to chance is how a precise rate limiting traffic spike becomes a data loss incident.
Testing
Each phase has a focused test: window counter, timestamp trim, and boundary check are exercised in isolation, then an integration test drives the full precise rate limiting pipeline and asserts the a burst exactly at case is handled.
Start with unit tests that pin each component’s contract: the window counter returns the documented shape, the timestamp trim transforms correctly, and the boundary check fails in the documented way. These tests are fast and they localize regressions to the component that broke.
Then add an integration test that composes the components and drives a realistic precise rate limiting scenario end to end. Assert the happy path first, then deliberately trigger a burst exactly at the window boundary that the counter admits twice if the trim runs after the check and assert the system degrades rather than corrupts. Because the components are pure where practical, the integration test stays deterministic rather than flaky.
A precise rate limiting suite must also prove the negative: bad input is rejected at the boundary, overload is shed rather than queued indefinitely, and a partial failure does not leave the system in an inconsistent state. These are the tests that catch the regressions a happy-path suite never would.
The entire precise rate limiting suite runs with no external service; every dependency is either in-process or stubbed, so every case is reproducible in CI. That is what lets the boundary check failure-mode assertions live as tests rather than as a manual checklist that drifts out of date.
# run the precise rate limiting test suite
pnpm vitest run
Deployment and operations
Run at least two precise rate limiting instances behind a load balancer and share state in Redis. Set resource limits so precise rate limiting overload surfaces as backpressure rather than failure.
Run at least two stateless instances behind a load balancer, and move any shared state into a shared store so the precise rate limiting behavior is global rather than per instance. Size limits so the system sheds load before it runs out of memory, because a precise rate limiting system that OOMs under load fails in a way that is hard to reason about.
Drain on deploy: stop accepting new work, let in-flight precise rate limiting operations finish, then exit. Because the system holds no state that cannot be reconstructed, a rollout is a matter of cycling instances one at a time while watching the error rate.
When a dependency is unavailable, decide explicitly whether the precise rate limiting system fails closed or degrades. Leaving that choice to chance is how a dependency outage becomes a silent data loss, so make the choice visible in code and in a test.
Security considerations
Validate all precise rate limiting inputs at the boundary, reject oversized payloads before buffering them, and keep secrets in the environment rather than the repository.
The precise rate limiting system is a choke point, so it is the right place to reject malformed input before it reaches timestamp trim or boundary check. Validate the shape and the size at the edge, and let the inner components assume what they receive is well-formed.
Never log secrets or full payloads; log enough to localize a failure without recording data that should not be on disk. Treat any external call as untrusted and validate its response before using it, because a precise rate limiting system that trusts its inputs blindly is one malformed response away from corruption.
Keep dependencies pinned and run an audit in CI. A transitive dependency with a known vulnerability is still a vulnerability in your precise rate limiting system, and the lockfile is what makes the audit meaningful.
Observability
Emit one structured event per precise rate limiting operation with the inputs’ shape, the outcome, and a correlation id. Track error rate and latency per component and alert on a step change rather than a single spike.
The correlation id is the thread that ties a user-visible symptom to the precise rate limiting decision and to the component log entry for the same operation. Without it, an investigation starts from a timestamp and a guess.
Watch the boundary check failure rate separately from the overall error rate. A jump in boundary check failures usually means a burst exactly at the window boundary that the counter admits twice if the trim runs after the check, not a bug in timestamp trim, and the two have different owners and different fixes.
Chart the share of precise rate limiting operations that shed, queue, or succeed. A change in the mix points at the cause faster than an aggregate latency can, because each class has a different root.
Troubleshooting
A burst exactly at the window boundary that the counter admits twice if the trim runs after the check. Reproduce with a minimal fixture, then add a guard test so the precise rate limiting system rejects the condition loudly.
Performance regresses under precise rate limiting load. Profile with the sampling analyzer, locate the hot path in timestamp trim, and add a targeted benchmark to lock the regression.
Keep the precise rate limiting dashboards separate from dependency dashboards. When an operation fails, the correlation id lets you decide in seconds whether the fault is window counter, timestamp trim, or boundary check, instead of guessing from a timeout.
Limitations and next steps
- Single-region; multi-region replication is out of scope.
- The window counter is in-process and not horizontally shared by default.
- No auth layer; assume deployment behind an edge that enforces identity.
The precise rate limiting system is single-region by design; extending to multi-region means making window counter shareable across regions, which is a real change to the consistency model rather than a config flag. The boundary check is in-process, which is correct for one instance but must move to a shared store before scaling out.
There is no authentication or authorization layer; the system assumes it is deployed behind an edge that enforces identity. Adding one is straightforward but out of scope for this build, and doing it here would distract from the precise rate limiting correctness that is the point.
The natural next step, once the precise rate limiting pipeline is trusted in production, is to add a short-TTL cache for idempotent reads and a richer distributed sync that exports histograms rather than counters. Both are additive and neither changes the contracts this build establishes.