npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

ratelimit-primitives

v1.1.0

Published

Rate limiting algorithms as pure functions — GCRA, token bucket, sliding window, fixed window. JSON-serializable state, no clock reads, no I/O, no dependencies. Runs in Cloudflare Workers, Durable Objects, Deno, browsers and Node.

Readme

ratelimit-primitives

npm version npm downloads

Rate limiting algorithms, not a rate limiting framework.

Every other package in this space couples the algorithm to a storage backend, a middleware layer, or both. This one ships the math and hands the state back to you:

Algorithm state is a plain JSON-serializable object. State transitions are pure synchronous functions of (state, now, cost). The package never reads the clock, never allocates a timer, and never performs I/O in its core.

That is the whole design. It is what lets the same limiter run inside a Cloudflare Durable Object, Workers KV, Deno KV, IndexedDB, a Postgres row, a Redis value you manage yourself — or a plain Map — without an adapter for each.

  • Zero dependencies. No polyfills, no node: imports in the core.
  • Edge-native. Runs unchanged in Workers, Deno, Bun, browsers and Node >= 18.
  • 1.2 kB gzipped for all six algorithms; 0.4 kB if you only import gcra.
  • Serializable state. Save it, ship it, restore it after a restart, diff it in a test.
import { gcra } from 'ratelimit-primitives/algorithms';

const limit = gcra({ limit: 100, periodMs: 60_000, burst: 10 });

// State is one number. Put it wherever you like.
let state = limit.init(Date.now()); // { tat: 1758124800000 }

const { state: next, verdict } = limit.step(state, Date.now());
if (verdict.ok) state = next;
// verdict → { ok: true, remaining: 9, retryAfterMs: 0, resetAfterMs: 600 }

Install

npm install ratelimit-primitives

| Import | What you get | Pulls in | | --------------------------------- | ------------------------------------------------------- | ------------------------------ | | ratelimit-primitives | createLimiter, memoryStore, every algorithm, types | everything below except timers | | ratelimit-primitives/algorithms | the six transition functions and nothing else | nothing | | ratelimit-primitives/async | createAsyncLimiter for Promise-based stores | nothing | | ratelimit-primitives/schedule | scheduler — the only module that touches setTimeout | nothing |

The split is deliberate: a Worker that only needs the math never bundles a store, and timers never enter an edge bundle that has no use for them.

Start with GCRA

The Generic Cell Rate Algorithm is a virtual scheduling limiter. It is the most useful algorithm here and the least available in the JS ecosystem, and its entire state is one timestamp:

gcra({ limit, periodMs, burst });
// state: { tat: number }

It shapes traffic to one unit every periodMs / limit, while allowing burst units back to back. Because the balance is the distance between the stored timestamp and the clock, it never accumulates fractional drift the way a token bucket does, and it costs 8 bytes per key in any store.

import { createLimiter, gcra } from 'ratelimit-primitives';

// 100 requests/minute sustained, up to 10 at once.
const limiter = createLimiter(gcra({ limit: 100, periodMs: 60_000, burst: 10 }));

const verdict = limiter.consume(`user:${userId}`);
if (!verdict.ok) {
    return new Response('Too Many Requests', {
        status: 429,
        headers: {
            'retry-after': String(Math.ceil(verdict.retryAfterMs / 1000)),
            'x-ratelimit-remaining': String(verdict.remaining),
        },
    });
}

Examples

Limiting your own outbound calls

The primary use case. A third-party API allows 10 requests/second and you would rather wait than get a 429 back. scheduler turns a limiter into a gate that queues:

import { createLimiter, gcra } from 'ratelimit-primitives';
import { scheduler } from 'ratelimit-primitives/schedule';

const limiter = createLimiter(gcra({ limit: 10, periodMs: 1000 }));
const throttled = scheduler(limiter, { key: 'stripe', maxWaitMs: 30_000 });

// 500 calls, paced at 10/s instead of hammering the API and eating 429s.
const charges = await Promise.all(
    invoices.map(invoice => throttled(() => stripe.charges.create(invoice)))
);

scheduler consumes a slot, runs the function when the verdict is ok, and otherwise sleeps exactly retryAfterMs and retries. Pass a signal to abort a pending wait — it rejects with a standard AbortError — and maxWaitMs to cap the cumulative wait, which rejects with a TimeoutError.

It is about forty lines and does not try to be bottleneck. No priorities, no clustering, no concurrency pool — and no queue, so waiting callers retry rather than hold a position. Ordering is therefore best-effort, not a guarantee. If you need real queue semantics, use bottleneck.

Inbound limiting with your own store

Store is three synchronous methods. Anything that can implement them works — an LRU, a Map, a WeakRef cache, a synchronous embedded KV:

import { createLimiter, slidingWindowCounter } from 'ratelimit-primitives';
import type { Store, SlidingWindowCounterState } from 'ratelimit-primitives';

const shards = new Map<string, SlidingWindowCounterState>();

const store: Store<SlidingWindowCounterState> = {
    get: key => shards.get(key),
    set: (key, state) => void shards.set(key, state),
    delete: key => void shards.delete(key),
};

const limiter = createLimiter(slidingWindowCounter({ limit: 60, windowMs: 60_000 }), { store });

export function handle(request: Request) {
    const ip = request.headers.get('cf-connecting-ip') ?? 'unknown';
    const verdict = limiter.consume(ip);
    return verdict.ok ? forward(request) : new Response('slow down', { status: 429 });
}

For a Promise-based backend, use createAsyncLimiter — same algorithms, awaited store:

import { gcra } from 'ratelimit-primitives/algorithms';
import { createAsyncLimiter } from 'ratelimit-primitives/async';
import type { GcraState } from 'ratelimit-primitives/algorithms';

const limiter = createAsyncLimiter<GcraState>(gcra({ limit: 20, periodMs: 1000 }), {
    store: {
        async get(key) {
            return (await env.KV.get<GcraState>(key, 'json')) ?? undefined;
        },
        async set(key, state) {
            await env.KV.put(key, JSON.stringify(state), { expirationTtl: 60 });
        },
        async delete(key) {
            await env.KV.delete(key);
        },
    },
});

const verdict = await limiter.consume(`ip:${ip}`);

Read the atomicity section before deploying that across multiple isolates.

Surviving a restart

No other package in this space can do this, because in every other package the state lives inside the limiter object. Here the state is the value you stored:

import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { createLimiter, gcra } from 'ratelimit-primitives';
import type { GcraState, Store } from 'ratelimit-primitives';

// Whatever the previous process left behind — one `{ tat }` per key.
const states = new Map<string, GcraState>(
    existsSync('limits.json') ? JSON.parse(readFileSync('limits.json', 'utf8')) : []
);

const store: Store<GcraState> = {
    get: key => states.get(key),
    set: (key, state) => void states.set(key, state),
    delete: key => void states.delete(key),
};

const limiter = createLimiter(gcra({ limit: 1000, periodMs: 3_600_000 }), { store });

process.on('SIGTERM', () => {
    writeFileSync('limits.json', JSON.stringify([...states]));
    process.exit(0);
});

The same trick applies to a deploy that rolls pods, a Lambda that warms up, a test that needs to reproduce an exact limiter state, or a debug endpoint that dumps every active budget. State is data, so JSON.parse(JSON.stringify(state)) is guaranteed to behave identically — there is a test asserting exactly that for every algorithm.

// Sending a limiter's state to another machine is a POST, not a protocol.
const state = algorithm.step(JSON.parse(body), Date.now(), cost);

Choosing an algorithm

| Algorithm | State | Exact? | Burst behaviour | Pick it when | | ---------------------- | --------------- | ----------- | ----------------------------------------- | ------------------------------------------------------ | | gcra | 1 number | yes | smooth pacing, explicit burst allowance | default. Long-running limits, tiny state, no drift | | slidingWindowCounter | 3 numbers | approximate | absorbs a burst, decays it linearly | inbound per-IP limits where pacing is unwanted | | tokenBucket | 2 numbers | yes | full bucket spends at once | you think in tokens and the process is short-lived | | leakyBucket | 2 numbers | yes | identical to tokenBucket | your spec is written in leaky-bucket language | | fixedWindow | 2 numbers | no | 2× limit across a boundary | a spec literally says "N per calendar minute" | | slidingWindowLog | limit numbers | yes | exact, no artefacts | exactness beats memory, limit is small |

The honest short version: use gcra. Use slidingWindowCounter if you want plain "N per window" semantics that a burst can fill immediately. Everything else is here because a spec or a habit asks for it.

gcra

gcra({ limit: 100, periodMs: 60_000, burst: 10 }); // state: { tat: number }

burst defaults to limit. Idle time buys back at most burst units — an hour of silence does not grant an hour of quota.

slidingWindowCounter

slidingWindowCounter({ limit: 60, windowMs: 60_000 }); // state: { start, prev, curr }

Weighted approximation: estimate = prev * overlapFraction + curr. It removes fixed-window boundary amplification at the same state size, and errs by assuming the previous window's requests were spread evenly across it. Small, conservative, and the sensible default for most inbound limiting.

tokenBucket

tokenBucket({ capacity: 10, refillPerPeriod: 10, periodMs: 1000 }); // state: { tokens, updatedAt }

Tokens refill continuously from elapsed time — there is no drip interval and no timer.

Caveat: the balance is a float rewritten on every call, so tokens + elapsed * rate accumulates rounding error under sustained high-frequency use, and very short elapsed windows lose precision. gcra produces the same shaping without the drift because it stores an integer-millisecond timestamp instead of a balance. Prefer gcra for anything long-running.

leakyBucket

leakyBucket({ capacity: 10, leakPerPeriod: 10, periodMs: 1000 }); // state: { tokens, updatedAt }

Leaky-bucket-as-a-meter is mathematically isomorphic to token bucket; this alias exists for familiarity, not because it is a distinct algorithm. It is literally tokenBucket with the parameters renamed, and the drift caveat applies unchanged.

The queueing variant — leaky bucket as a shaper, which delays instead of rejecting — is deliberately not implemented. That needs a queue and timers; use scheduler from ./schedule instead.

fixedWindow

fixedWindow({ limit: 100, windowMs: 60_000 }); // state: { start, count }

Known flaw — boundary amplification: limit requests at the end of one window plus limit more just after the boundary means 2× limit passes within a span shorter than one window. There is a test in this repo that asserts exactly that happens. It is included because it is the baseline people ask for by name, not because it is a good choice. slidingWindowCounter fixes it at the same state size.

slidingWindowLog

slidingWindowLog({ limit: 20, windowMs: 60_000, maxEntries: 20 }); // state: { hits: number[] }

Exact, with no approximation or boundary artefacts, because it keeps a timestamp per admitted unit and prunes expired ones on every step.

The price is memory: state is O(limit) per key — by far the largest here — which makes it a poor fit for stores that serialize on every request (KV, a database row, a Durable Object write). At limit: 1000 every call reads and writes a thousand-element array. maxEntries (default limit) hard-caps the array so state arriving from a store cannot grow it without bound; it must be at least limit, since the array is the count and trimming below limit would erase live hits and admit past the limit. Fractional costs round up, since a stored timestamp is indivisible.

Verdicts

interface Verdict {
    ok: boolean; // permitted?
    remaining: number; // further units of cost 1 permitted right now — integer, >= 0
    retryAfterMs: number; // until this same request would pass; 0 when ok
    resetAfterMs: number; // until fully replenished
}

Rules that hold for every algorithm, and are asserted for every algorithm in the test suite:

  • A rejected request costs nothing. step returns the same state reference it was given. Rejections never consume budget and never advance time.
  • step never mutates its input. Accepted requests return a new object.
  • retryAfterMs is exact. Advance now by exactly retryAfterMs and the same request is admitted; one millisecond earlier it still is not.
  • State survives JSON.parse(JSON.stringify(state)) with identical behaviour.
  • cost defaults to 1 and accepts any positive number. Non-finite, zero or negative values throw a TypeError.
  • A cost that can never fit — larger than burst, capacity or limit — is rejected with retryAfterMs: Infinity rather than a wait that would never end. scheduler turns that into a RangeError instead of sleeping forever.
  • No algorithm reads a clock. now is always supplied by the caller, in milliseconds.

Atomicity

This package does not make your rate limiting distributed-safe, and pure functions cannot.

consume is a read-modify-write. With a store shared across processes or isolates, two concurrent calls can both read the same state, both decide they fit, and both write — effectively doubling your limit for that instant. The transition function being pure does not help: the race is in the store, not the math.

There is a test in this repo that asserts this happens with createAsyncLimiter over a naive store, so that nobody discovers it in production.

If you need correctness under concurrency, the store has to provide it:

  • Compare-and-swap. Read the state with a version or ETag, compute the transition, write conditionally, retry on conflict. Cheap when contention is low.
  • Server-side execution. A Redis Lua script, a Postgres UPDATE ... RETURNING inside a transaction, a stored procedure — anything that makes read and write one round trip.
  • A single-threaded owner. A Cloudflare Durable Object per key is the cleanest fit for this package: the DO gives you serialization, this package gives you the transition, and neither has to know about the other.

If approximate limiting is acceptable — and for most inbound abuse protection it is — a per-node limiter with a per-node share of the budget is simpler and has no coordination cost at all.

Clocks

createLimiter defaults to Date.now(). That is the only clock read in the package, and it is a compromise you should make consciously.

Date.now() is wall-clock and not monotonic. NTP correction, a VM resume, or a manual clock change can move it backwards or jump it forwards. Every algorithm here tolerates a backwards jump without corrupting state — elapsed time is clamped at zero, tat is clamped to now — but a forward jump grants quota that was never earned.

  • Purely local limiting (your outbound calls, one process, no shared state): pass now: () => performance.now(). It is monotonic and immune to clock adjustment. State is then meaningless outside that process — it does not survive a restart and cannot be shared.
  • Anything shared across machines (state in Redis, KV, a database): you need wall-clock, because two machines must agree on what now means. Clock skew between nodes then translates directly into limiter inaccuracy — a node 500 ms fast effectively grants itself 500 ms of extra budget. Keep NTP running and treat skew as part of your error budget.

There is no correct default for both cases, so this package picks the one that works with shared state and lets you override it:

const limiter = createLimiter(algorithm, { now: () => performance.now() });

The algorithms themselves take now as an argument, so if you are calling them directly, the question is entirely yours.

License

MIT