rateLimiter() is a middleware that caps how many requests a client can make in
a window. It sets the standard X-RateLimit-* and Retry-After headers, and
returns 429 Too Many Requests when the limit is exceeded.
It's a fixed-window limiter: each key gets a bucket that counts requests
until a fixed reset time, then starts over. Simple, cheap, and edge-safe — the
default store is a plain in-memory Map, so nothing is imported that can't run
on a Worker.
Global or per-route
import { rateLimiter } from "@shaferllc/keel/core";
// every request: 60 per minute per IP
export class Kernel extends HttpKernel {
constructor(app: Application) {
super(app);
this.use(rateLimiter({ max: 60, window: 60 }));
}
}
// a stricter limit on a sensitive route
router.post("/login", [AuthController, "login"]).use(rateLimiter({ max: 5, window: 60 }));
Each call to rateLimiter() owns its own bucket store, so a global limiter and a
per-route limiter count independently — a request to /login ticks both, but in
separate buckets. Stack as many as you like.
Options
rateLimiter({
max: 60, // requests per window (default 60)
window: 60, // window seconds (default 60)
key: (c) => c.req.header("x-api-key") ?? "anon", // bucket key (default: IP)
message: "Slow down!", // 429 body message
});
The key function decides what to limit by — per IP (default), per API key, per
user id, etc. Different keys get independent buckets. Return the same string to
share a bucket; return "global" (or any constant) to limit everyone together.
The default key
With no key, the bucket is derived from the client IP, tried in this order:
- the first entry of
X-Forwarded-For(trimmed), X-Real-IP,- the literal
"global"if neither header is present.
That fallback means that behind a proxy that strips those headers, every client
shares the "global" bucket — so set an explicit key if your platform doesn't
surface the client IP.
Response headers
Every response carries:
| Header | Meaning |
|---|---|
X-RateLimit-Limit |
the ceiling for the window |
X-RateLimit-Remaining |
requests left in the window |
Retry-After |
(on 429) seconds until the window resets |
On an allowed request the two X-RateLimit-* headers are written after the
handler runs. On a rejected request all three are set on the 429 itself.
What happens at the limit
The counter increments on every request. The request that pushes the count
past max is the one that's rejected — so max: 5 lets five requests through
and blocks the sixth within the window. The 429 body is:
{ "error": "Too Many Requests", "status": 429 }
error is your message if you passed one. Retry-After reports the whole
seconds until the bucket resets. Once the window elapses the next request starts
a fresh bucket at count 1.
Storage
The default store is in-memory — per process, and per isolate on the edge. That's fine for a single instance, but limits aren't shared across instances. The store self-prunes: once it holds more than 10,000 keys it sweeps expired buckets on the next request, so a large key space (e.g. per-IP) won't grow unbounded.
On anything horizontal, share the tally by passing a store:
import { rateLimiter, redisRateLimitStore, cacheRateLimitStore } from "@shaferllc/keel/core";
// Redis: counts with INCR — atomic, so a burst across nodes can't slip past.
this.use(rateLimiter({ max: 60, window: 60, store: redisRateLimitStore() }));
// Any Cache (database, KV): read-modify-write — simultaneous hits can
// under-count by a request or two. Fine for traffic shaping, not for billing.
this.use(rateLimiter({ max: 60, window: 60, store: cacheRateLimitStore() }));
Pass the same store instance to several rateLimiter() calls and they share
one tally; the backends namespace their keys under ratelimit:, so a shared
Redis or cache doesn't collide with your own entries. A custom backend is one
method — implement RateLimitStore.hit(key, windowMs) and return the running
{ count, reset } for the window.
API reference
rateLimiter(options?)
rateLimiter(options?: RateLimiterOptions): MiddlewareHandler
Builds a fixed-window rate-limiting middleware. Each returned handler keeps its
own private bucket store, counts requests per key within the window, sets the
X-RateLimit-* headers, and short-circuits with 429 past the limit.
import { rateLimiter } from "@shaferllc/keel/core";
const limit = rateLimiter({ max: 100, window: 60 });
// register it: this.use(limit) globally, or .use(limit) on a route
Notes: all options are optional — rateLimiter() with no arguments is 60
requests per 60 seconds, keyed by client IP. The returned handler is a Hono
MiddlewareHandler, so it works anywhere middleware is accepted (use, per
route, per group). Buckets live in a closure over the single call, so reusing the
same handler shares state while a second rateLimiter(...) call does not.
The 429 is returned before next(), so downstream handlers never run for a
throttled request.
Interfaces & types
RateLimiterOptions
interface RateLimiterOptions {
max?: number; // requests per window; default 60
window?: number; // window length in seconds; default 60
key?: (c: Context) => string; // bucket key; default: client IP
message?: string; // 429 body message; default "Too Many Requests"
store?: RateLimitStore; // where counters live; default: in-memory
}
The shape you pass to rateLimiter(). Every field is optional; key receives the
Hono Context and returns the string that identifies the bucket.
import { rateLimiter, type RateLimiterOptions } from "@shaferllc/keel/core";
const perUser: RateLimiterOptions = {
max: 30,
window: 60,
key: (c) => c.req.header("authorization") ?? "anon",
message: "Easy there — try again shortly.",
};
rateLimiter(perUser);
Notes: window is seconds, not milliseconds (it's multiplied by 1000
internally). key is called on every request, so keep it cheap and pure. Returning
a constant string collapses all clients into one shared bucket.
RateLimitStore / RateLimitBucket
interface RateLimitStore {
hit(key: string, windowMs: number): Promise<RateLimitBucket> | RateLimitBucket;
}
interface RateLimitBucket {
count: number; // hits so far in the current window, including this one
reset: number; // epoch ms when the window rolls over
}
The storage seam. hit records one request against key, rotating the window
if it lapsed, and returns the running tally. Shipped implementations:
MemoryRateLimitStore (the default), redisRateLimitStore(client?) (atomic),
and cacheRateLimitStore(cache?) (best-effort over any Cache) — the latter
two default to the app's redis() client and cache() singleton.