bitbaum Start a project
Package · MIT

limitkit

Rate limiting as a decision, not middleware. Bounded memory by construction, standard headers.

Why it exists

Public routes were growing one-off counters that forgot to bound themselves, and that told the client nothing when they said no. The failure mode is a process that remembers every IP forever.

How it works

Fixed and sliding windows over a store you pass in, with a bounded in-memory store as the default. The decision is separate from the framework. The response carries the standard rate-limit headers, and the client IP is read in one place.

Where it fits

It is what lets a small studio leave a door open — hire, pay links, public APIs — without one caller becoming the outage. Seventeen apps already depend on that being true.

LicenceMIT
Registry releasev0.2.1
Install
Sourcebitbaum/limitkit
Declared consumers17 repositories

API and examples

Documentation from source commit d55dd07b3570 · 2026-11-07. Source documentation can include work beyond the registry release.

Read the package documentation

Rate limiting as a decision, not a middleware.

pnpm add @bitbaum/limitkit

Why this exists

Twelve near-identical rate limiters were found across nine repos of one fleet — orangecat alone carried four, and its ADR to unify them sat "Proposed" for seven months while the count doubled. What actually varied between the twelve was one thing: where the counts live. Everything else — the window arithmetic, the refusal shape, the headers, the client-IP dance — was the same idea written twelve slightly different ways, with independently re-invented bugs (one kept an unbounded Map keyed by stranger IPs: a slow memory leak fed by the public internet).

So the algorithm is fixed and the store is the seam.

Use

import { slidingWindow, clientIp, toHeaders } from '@bitbaum/limitkit';

const loginLimiter = slidingWindow({ limit: 5, windowMs: 15 * 60_000 });

export async function POST(req: Request) {
  const result = loginLimiter.check(`login:${clientIp(req.headers)}`);
  if (!result.allowed) {
    return Response.json(
      { error: 'Too many attempts' },
      { status: 429, headers: toHeaders(result) },
    );
  }
  // ... the actual work, with toHeaders(result) on the success path too if you like
}
  • slidingWindow(rule, store?) — N hits per trailing window. The default;

it is what most hand-rolled "fixed" windows actually meant.

  • fixedWindow(rule, store?) — N hits per aligned bucket. Cheaper, burstier

at the boundary (up to 2N around a roll). Choose it by name, not by accident.

  • check counts the hit when allowed and counts nothing when refused —

a hammered key recovers the moment the attacker stops, instead of locking the legitimate user behind the same NAT out forever.

  • The refusal's retryAfterSeconds is derived from the actual oldest hit,

never fabricated. "Try again shortly" on a window that opens in an hour is a lie; this package refuses to tell it.

The store seam

interface Store {
  get(key: string): { hits: number[] } | undefined;
  set(key: string, state: { hits: number[] }): void;
}

The default MemoryStore is bounded (LRU past maxKeys, default 5 000) — the unbounded-Map leak is impossible by construction. It is per-process: behind N workers the effective limit multiplies by N, which is fine for blunting scripted abuse and wrong for billing enforcement. For shared state, implement those two methods over Redis/Postgres — an adapter is a dozen lines, because the store holds and the algorithm decides.

HTTP edges

  • toHeaders(result) — X-RateLimit-Limit / -Remaining / -Reset, plus

Retry-After on refusals only. A plain object you spread into your own response; constructing a Response would mean choosing your framework for you.

  • clientIp(headers, { trustedProxies = 1 }) — the forwarded hop your own

proxy wrote, then x-real-ip, then "unknown". A proxy appends to X-Forwarded-For, so the header reads <what the client sent>, <what the proxy saw> and only the last entry is unforgeable. Reading the first one — which this did before v0.2.0 — lets a caller vary the header per request, mint a fresh bucket each time and never trip the limit at all. Set trustedProxies: 2 when a CDN sits in front of your proxy, or 0 when the server is exposed directly and no forwarded header can be believed.

  • No HTTP client, no middleware, no framework types — the package supplies

the decision; your app keeps its conventions.

  • No limit values — how many attempts a login route allows is app

semantics. Centralize the rule; assert the numbers locally.

  • No distributed store — that is your Redis and your ops posture. The

seam is two methods.

Everything takes now as an argument, so all of it is testable without sleeping — which is why this package has tests and the twelve files it replaces, collectively, had almost none.

Development

pnpm run verify  # lint + typecheck + build + test (tests import by package name)

MIT.


Part of bitbaum — AI-native products on open infrastructure, built in Zürich. Every package here lists the apps that use it: which apps use limitkit.

Changelog and releases

Read the repository changelog

Renamed — limitkit is now @bitbaum/limitkit

Same code, same version, same repository; the package now lives under the @bitbaum npm organization like the rest of the fleet's packages. Install @bitbaum/limitkit and import from it (limitkit stays installable and is deprecated once no adopter uses it). No API changed.

This retrospective record was added on 2026-10-27 from the immutable sources linked below. Git commit dates are source dates, not inferred npm publication dates. A GitHub release does not itself establish npm publication. Application adoption and deployment evidence are tracked separately.

0.2.0

No authored release notes were found for this tag when this record was made. The links identify the verified source; missing feature history is not invented.

  1. v0.2.1
    Release notes

    What's Changed

    * chore: add a dependency watcher by @catomean in https://github.com/bitbaum/limitkit/pull/3 * chore: declare node >=20, the version the box actually runs by @catomean in https://github.com/bitbaum/limitkit/pull/9 * chore: add a formatter by @catomean in https://github.com/bitbaum/limitkit/pull/10 * chore(deps): currency sweep — eslint 10, @types/node 26, CI node 24, actions v7 by @catomean in https://github.com/bitbaum/limitkit/pull/11 * chore(deps): typescript 6 by @catomean in https://github.com/bitbaum/limitkit/pull/12 * chore: pnpm — the fleet has one package manager by @catomean in https://github.com/bitbaum/limitkit/pull/13 * docs: sync docs with actual stack by @catomean in https://github.com/bitbaum/limitkit/pull/14 * chore(ci): adopt the fleet auto-merge sweep by @catomean in https://github.com/bitbaum/limitkit/pull/15 * chore(deps-dev): Bump the minor-and-patch group with 2 updates by @dependabot[bot] in https://github.com/bitbaum/limitkit/pull/16 * chore(deps-dev): Bump globals from 16.5.0 to 17.12.0 by @dependabot[bot] in https://github.com/bitbaum/limitkit/pull/17 * ci(auto-merge): hand the sweep a PAT so workflow PRs stop stalling by @catomean in https://github.com/bitbaum/limitkit/pull/18 * ci(auto-merge): correct the reason this repo passes a PAT by @catomean in https://github.com/bitbaum/limitkit/pull/19 * chore(deps-dev): Bump the minor-and-patch group with 3 updates by @dependabot[bot] in https://github.com/bitbaum/limitkit/pull/20 * chore: rename FleetCrown to Loki by @catomean in https://github.com/bitbaum/limitkit/pull/21 * docs: link back to bitbaum, and to who uses this package by @catomean in https://github.com/bitbaum/limitkit/pull/22 * ci(publish): tag-driven npm Trusted Publishing by @catomean in https://github.com/bitbaum/limitkit/pull/25 * chore(deps-dev): Bump the minor-and-patch group across 1 directory with 4 updates by @dependabot[bot] in https://github.com/bitbaum/limitkit/pull/24 * chore(deps-dev): Bump the minor-and-patch group with 4 updates by @dependabot[bot] in https://github.com/bitbaum/limitkit/pull/26 * chore(deps-dev): Bump the minor-and-patch group with 3 updates by @dependabot[bot] in https://github.com/bitbaum/limitkit/pull/27 * chore(deps-dev): Bump the minor-and-patch group with 2 updates by @dependabot[bot] in https://github.com/bitbaum/limitkit/pull/28 * chore: add the fleet package template files this repo is missing (git-release.yml, release.yml) by @catomean in https://github.com/bitbaum/limitkit/pull/29 * chore: add the fleet package template files this repo is missing (AGENTS.md, CLAUDE.md) by @catomean in https://github.com/bitbaum/limitkit/pull/30 * chore: add the fleet package template files this repo is missing (release-notify.yml) by @catomean in https://github.com/bitbaum/limitkit/pull/31 * docs: record verified package release history by @catomean in https://github.com/bitbaum/limitkit/pull/32 * docs(readme): install from npm, the way the registry says by @catomean in https://github.com/bitbaum/limitkit/pull/33 * ci(publish): the fleet's one publish.yml, with a no-publish check of the npm link by @catomean in https://github.com/bitbaum/limitkit/pull/34 * docs(agents): the org rules block, generated from bitbaum/fleet by @catomean in https://github.com/bitbaum/limitkit/pull/35 * chore(deps): dependabot leaves TypeScript 7 alone until typescript-eslint supports it by @catomean in https://github.com/bitbaum/limitkit/pull/36 * fix(release): respect ignored generated build output by @catomean in https://github.com/bitbaum/limitkit/pull/37

    New Contributors

    * @dependabot[bot] made their first contribution in https://github.com/bitbaum/limitkit/pull/16

    Full Changelog: https://github.com/bitbaum/limitkit/compare/v0.2.0...v0.2.1

Recent source changes

Repository activity can include unreleased work; it is not a list of deployed changes.

  1. chore(deps-dev): Bump the minor-and-patch group with 3 updates (#43)
  2. feat: publish as @bitbaum/limitkit (same code, same version) (#42)
  3. docs(agents): the org rules block, generated from bitbaum/fleet (#41)
  4. chore(deps-dev): Bump typescript-eslint (#40)
  5. chore(deps): clear dev-toolchain audit findings inside existing ranges (#39)
  6. docs(agents): the org rules block, generated from bitbaum/fleet (#38)
  7. release v0.2.1
  8. fix(release): respect ignored generated build output (#37)
All releases · All tags

Where it is declared

Linked products declare this package in their manifests. A declaration alone does not verify every feature, version or deployed integration.

Sources and verification

Install commands and declared consumers come from Fleet’s generated registry. Documentation and release notes come from the package repository. The catalogue does not certify production journey coverage.

Documentation from source commit d55dd07b3570 · 2026-11-07. Source documentation can include work beyond the registry release.

Package verification runs