Rate limiting middleware for Hono, backed by Redis. Supports four algorithms - fixed window, sliding window, token bucket, and leaky bucket (GCRA) behind one config-driven middleware, with atomic Lua-script checks so counts stay correct under real concurrent traffic.
Works on regular Node servers (via ioredis) and edge/serverless runtimes like Cloudflare Workers (via Upstash's REST-based Redis client).
- Four algorithms - pick whichever fits your traffic shape, per rule
- Atomic by design - every check runs as a single Lua script inside Redis, so concurrent requests can't race past the limit
- Edge-ready - the Upstash store works anywhere
fetchexists, no persistent connection required - Privacy-conscious keys - identifiers (IPs, user IDs, etc.) are hashed before ever touching Redis
- No silent defaults on failure - you explicitly choose fail-open or fail-closed behavior when Redis is unreachable
- Standard headers - both IETF
RateLimit-*header drafts supported, plusRetry-After - Multi-rule composition -
combineRules()evaluates several rules in one middleware; stackingrateLimiter()still works
pnpm install limitwall hono
# pick the store(s) you need:
pnpm install ioredis # for regular Node servers
pnpm install @upstash/redis # for edge/serverless runtimesimport { serve } from "@hono/node-server";
import { Hono } from "hono";
import { Redis } from "ioredis";
import { NodeRedisStore, rateLimiter } from "limitwall";
const app = new Hono();
const store = new NodeRedisStore({ client: new Redis() });
app.use(
"/api/*",
rateLimiter({
algorithm: "tokenBucket",
ruleName: "api",
capacity: 10,
rate: 0.5,
identifier: c => c.req.header("x-forwarded-for") ?? "unknown",
store,
onError: "fail-open",
}),
);
serve(app);import { Redis } from "@upstash/redis/cloudflare";
import { Hono } from "hono";
import { rateLimiter, UpstashRedisStore } from "limitwall";
const app = new Hono<{
Bindings: { UPSTASH_URL: string; UPSTASH_TOKEN: string };
}>();
app.use("/api/*", async (c, next) => {
const store = new UpstashRedisStore({
client: new Redis({ url: c.env.UPSTASH_URL, token: c.env.UPSTASH_TOKEN }),
});
return rateLimiter({
algorithm: "leakyBucket",
ruleName: "api",
capacity: 5,
rate: 2,
identifier: c => c.req.header("cf-connecting-ip") ?? "unknown",
store,
onError: "fail-closed",
})(c, next);
});
export default app;| Algorithm | Config fields | Behavior |
|---|---|---|
fixedWindow |
limit, windowSeconds |
Simplest. Resets on the dot every window; allows a burst right at the boundary. |
slidingWindow |
limit, windowSeconds |
Blends the current and previous window's counts, avoiding the boundary-burst problem. |
tokenBucket |
capacity, rate |
Bucket refills continuously; allows saved-up bursts up to capacity. |
leakyBucket |
capacity, rate |
Implemented as GCRA. Smooths traffic into an even pace rather than allowing bursts. See note below on remaining/reset values. |
Every algorithm also accepts ruleName (required - namespaces its Redis keys). tokenBucket and leakyBucket also accept cost (optional, defaults to 1). Window algorithms always count one request.
Note on
leakyBucket: unlike the other three algorithms, GCRA doesn't track a discrete "count remaining" - it tracks a single theoretical arrival time. As a result,RateLimit-RemainingandRateLimit-Resetare not meaningful on allowedleakyBucketrequests and will read0. This is expected, not a bug. The value that is meaningful for this algorithm isRetry-After, which is only set on rejected requests and tells the caller exactly how long to wait.
interface RateLimiterConfig {
algorithm: "tokenBucket" | "fixedWindow" | "slidingWindow" | "leakyBucket";
ruleName: string;
// ...algorithm-specific fields, see table above
// cost?: number — tokenBucket and leakyBucket only, defaults to 1
identifier: (c: Context) => string; // required - what to rate limit by
store: RateLimitStore; // NodeRedisStore or UpstashRedisStore
onError: "fail-open" | "fail-closed"; // required - no silent default
headerStyle?: "draft-6" | "draft-7"; // defaults to "draft-6"
message?: (c: Context) => Response; // overrides the default 429 body
}combineRules() takes an array of the same configs you would pass to rateLimiter(). Rules run in order. The first reject wins and later rules are not charged. On allow, headers come from the rule with the lowest remaining (or the last rule if none report remaining, as with leaky bucket). Each rule still uses one Redis round trip.
Stacking multiple rateLimiter() middlewares remains valid.
import { combineRules, NodeRedisStore } from "limitwall";
app.use(
"/api/*",
combineRules([
{
algorithm: "fixedWindow",
ruleName: "per-ip",
limit: 100,
windowSeconds: 60,
identifier: c => c.req.header("x-forwarded-for") ?? "unknown",
store,
onError: "fail-closed",
},
{
algorithm: "tokenBucket",
ruleName: "per-user",
capacity: 20,
rate: 1,
identifier: c => c.get("userId") ?? "anon",
store,
onError: "fail-closed",
},
]),
);On every request:
RateLimit-Limit,RateLimit-Remaining,RateLimit-Reset(draft-6, default), orRateLimit: limit=..., remaining=..., reset=...(draft-7, if configured)
On a rejected leaky-bucket request, Retry-After is also set, telling the client exactly how long to wait. See the note under Algorithms - RateLimit-Remaining/RateLimit-Reset are not meaningful for leakyBucket outside of that rejection case.
rateLimiter() is a thin Hono wrapper around four plain async functions that have no framework dependency - only a store and an identifier string. They're exported directly for use in plain Node scripts, background jobs, or other frameworks:
import { checkTokenBucket, NodeRedisStore } from "limitwall";
const store = new NodeRedisStore({ client: new Redis() });
const result = await checkTokenBucket(store, "user-123", {
ruleName: "api",
capacity: 10,
rate: 0.5,
});
if (!result.allowed) {
// handle rejection yourself - no Hono context required
}The other three follow the same shape: checkFixedWindow, checkSlidingWindow, checkLeakyBucket.
pnpm install
pnpm run build # tsup -> dist/
pnpm run test # vitest, spins up real Redis via Testcontainers
pnpm run lint # eslint (antfu config)Tests use Testcontainers to run against a real, disposable Redis instance - Docker must be running locally.
MIT © Kevin Rozario