Skip to content

Add fixed-window rate limit middleware - #11576

Open
mvanhorn wants to merge 5 commits into
remix-run:mainfrom
mvanhorn:fix/remix-rate-limit-middleware
Open

Add fixed-window rate limit middleware#11576
mvanhorn wants to merge 5 commits into
remix-run:mainfrom
mvanhorn:fix/remix-rate-limit-middleware

Conversation

@mvanhorn

@mvanhorn mvanhorn commented Jun 30, 2026

Copy link
Copy Markdown

Adds fixed-window rate limiting for Remix Fetch API servers through remix/middleware/rate-limit.

import { memoryStore, rateLimit } from 'remix/middleware/rate-limit'

rateLimit({
  name: 'api',
  limit: 100,
  window: 60_000,
  key: getAuthenticatedClientId,
  store: memoryStore(),
})

The API requires applications to choose an explicit client key because Fetch requests do not expose a trusted client address. Named policies allow global and route-specific limits to share a store and compose their response fields without collisions.

Stores expose one atomic fixed-window increment() operation. The included memory store is an explicit, single-process option; multi-process or multi-host deployments can supply a shared store. Responses use the current IETF draft's named RateLimit and RateLimit-Policy structured fields, with Retry-After on rejected requests.

The package includes coverage for request boundaries, independent clients, policy composition, custom 429 responses, window rollover, invalid configuration and client keys, store failures, structured-field serialization, and memory-store rotation.

@mjackson mjackson changed the title feat(rate-limit-middleware): add @remix-run/rate-limit-middleware Add fixed-window rate limit middleware Jul 16, 2026
@mjackson

mjackson commented Jul 16, 2026

Copy link
Copy Markdown
Member

Thanks for the PR, MVH!

I pushed a follow-up that narrows this to the minimum safe fixed-window API and addresses the issues found while comparing it with Hono's middleware:

  • Client identity is now explicit and required in the middleware options. A Fetch Request has no trusted remote address, and inferring identity from authorization/cookie/user-agent/origin headers either lets clients rotate buckets or makes unrelated clients share one. The README guidance now calls that out.
  • Policies now require a name, and the atomic store operation receives { name, key, window }. This prevents collisions when global and route-specific limiters share a store and removes the race between separate read/write calls.
  • Store selection is explicit. The included generational memory store is intentionally single-process and rotates maps instead of doing an O(n) bucket scan on every request. Requiring it explicitly avoids implying that an in-memory default coordinates across processes or hosts.
  • Response fields now use the current IETF draft's named structured-field form, and fields are appended so nested policies compose.
  • I removed the unused strategy abstraction and single shared context state. The resulting implementation is one fixed-window limiter with a small public surface, custom responses normalized to 429, validation at trust boundaries, and propagated store failures.
  • Coverage now exercises policy composition, rejection behavior, independent clients, rollover, invalid inputs/store output, header serialization, and memory-store isolation/rotation. The focused package suite has 15 passing tests; full workspace tests and typechecking also pass locally.

The package is also wired into the Remix umbrella export as remix/middleware/rate-limit, with release change files included.

@mjackson mjackson closed this Jul 16, 2026
@mjackson mjackson reopened this Jul 16, 2026
@mvanhorn

Copy link
Copy Markdown
Author

Thanks for the hardening pass, Michael. I read through the follow-up and the narrower API is better than what I had.

The client-identity change is the one I'd have gotten wrong: a Fetch Request has no trusted remote address, and inferring identity from auth/cookie/UA/origin headers is either a bucket-rotation hole or makes unrelated clients share a bucket, so requiring it explicitly is the right boundary. Passing { name, key, window } into a single atomic store op also removes the read-then-write race my version had and the collisions when a global and a route-scoped policy share a store.

Dropping the strategy abstraction and shared context for one fixed-window limiter is the right scope for a first middleware, and rotating maps in the memory store instead of scanning buckets per request is a good defualt. Happy with the umbrella export as remix/middleware/rate-limit.

Anything left you want me to pick up, or is this good to land as-is?

…t-middleware

# Conflicts:
#	packages/remix/.changes/minor.remix.update-exports.md
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants