@jellyfungus/hono-rate-limiter
v0.6.0
Published
Production-ready rate limiting middleware for Hono web framework
Maintainers
Readme
@jellyfungus/hono-rate-limiter
Production-ready rate limiting middleware for Hono web framework.
Highlights
- Sliding Window Algorithm - Same approach used by Cloudflare, AWS, and major CDNs. Prevents burst attacks at window boundaries.
- Zero Dependencies - Only Hono as a peer dependency. No bloat.
- Works Out of the Box - Sensible defaults with built-in IP detection. Just add
rateLimiter()and you're done. - Multiple Stores - Memory (default), Redis (with atomic Lua scripts), Cloudflare KV
- Cloudflare Rate Limiting Binding - Native support for Cloudflare's globally distributed rate limiter
- WebSocket Support - Rate limit WebSocket connections
- Standard Rate Limit Headers - Support for both IETF standard (
RateLimit) and legacy (X-RateLimit-*) headers - TypeScript First - Complete type safety
Installation
npm install @jellyfungus/hono-rate-limiter
# or
bun add @jellyfungus/hono-rate-limiter
# or
pnpm add @jellyfungus/hono-rate-limiterQuick Start
import { Hono } from "hono";
import { rateLimiter } from "@jellyfungus/hono-rate-limiter";
const app = new Hono();
// That's it! 60 requests per minute with sliding window
app.use(rateLimiter());
app.get("/", (c) => c.text("Hello!"));
export default app;Sliding Window Algorithm
Traditional fixed-window rate limiters have a critical flaw: users can burst 2x the limit at window boundaries.
Our sliding window algorithm (same approach used by Cloudflare) smooths traffic across boundaries:
Fixed Window Problem:
|-------- Window 1 --------|-------- Window 2 --------|
[60 req][60 req]
└── 120 requests in seconds! ──┘
Sliding Window Solution:
|-------- Window 1 --------|-------- Window 2 --------|
Weighted calculation prevents bursts
└── Always respects the 60 req limit ──┘Configuration
app.use(
rateLimiter({
limit: 100, // Max requests per window (default: 60)
windowMs: 60 * 1000, // Window duration in ms (default: 60000)
algorithm: "sliding-window", // or "fixed-window" (default: sliding-window)
headers: "legacy", // Header format (see Response Headers section)
}),
);All Options
| Option | Type | Default | Description |
| ------------------------ | ----------------------------------------------------------- | ------------------ | ------------------------------------ |
| limit | number \| Function | 60 | Max requests per window |
| windowMs | number | 60000 | Window duration in ms |
| algorithm | 'sliding-window' \| 'fixed-window' | 'sliding-window' | Rate limiting algorithm |
| store | RateLimitStore | MemoryStore | Storage backend |
| keyGenerator | Function | IP detection | Generate unique client key |
| handler | Function | 429 response | Custom rate limit response |
| headers | 'legacy' \| 'draft-6' \| 'draft-7' \| 'standard' \| false | 'legacy' | Header format (see below) |
| identifier | string | 'default' | Policy name for IETF headers |
| quotaUnit | 'requests' \| 'content-bytes' \| 'concurrent-requests' | 'requests' | Quota unit for IETF standard headers |
| skip | Function | - | Skip rate limiting conditionally |
| skipSuccessfulRequests | boolean | false | Don't count 2xx responses |
| skipFailedRequests | boolean | false | Don't count 4xx/5xx responses |
| onRateLimited | Function | - | Callback when rate limited |
| onStoreError | 'allow' \| 'deny' \| Function | 'allow' | Behavior on store failure |
| dryRun | boolean | false | Monitor without blocking requests |
Stores
Choosing a Store
| Store | Best For | Consistency | Scalability | | ---------------------- | ---------------------------- | ------------------- | -------------------- | | MemoryStore | Single instance, development | Strong | Single instance only | | RedisStore | Multi-instance, production | Strong (atomic Lua) | Horizontal | | CloudflareKVStore | Cloudflare Workers | Eventual (~60s) | Global | | Cloudflare Binding | Enterprise Cloudflare | Strong | Global, managed |
Recommendations:
- Development/Testing: Use default MemoryStore
- Single server Node.js: MemoryStore is fine
- Multiple servers/containers: Use RedisStore
- Cloudflare Workers (strict): Use Rate Limiting binding or Durable Objects
- Cloudflare Workers (relaxed): CloudflareKVStore is acceptable
Memory Store (Default)
Perfect for single-instance deployments:
app.use(rateLimiter()); // Uses MemoryStore automaticallyRedis Store
For distributed deployments. Uses atomic Lua scripts for race-condition-free operations:
import { rateLimiter } from "@jellyfungus/hono-rate-limiter";
import { RedisStore } from "@jellyfungus/hono-rate-limiter/store/redis";
import Redis from "ioredis";
const redis = new Redis(process.env.REDIS_URL);
app.use(
rateLimiter({
store: new RedisStore({ client: redis }),
}),
);Cloudflare KV Store
For Cloudflare Workers with KV:
Warning: Cloudflare KV is eventually consistent and does not support atomic increment operations. Under high concurrency, rate limits may be slightly exceeded. For strict rate limiting on Cloudflare, use Durable Objects or the native Rate Limiting binding below.
import { rateLimiter } from "@jellyfungus/hono-rate-limiter";
import { CloudflareKVStore } from "@jellyfungus/hono-rate-limiter/store/cloudflare-kv";
type Bindings = { RATE_LIMIT_KV: KVNamespace };
const app = new Hono<{ Bindings: Bindings }>();
app.use("*", async (c, next) => {
const limiter = rateLimiter({
store: new CloudflareKVStore({ namespace: c.env.RATE_LIMIT_KV }),
});
return limiter(c, next);
});Cloudflare Rate Limiting Binding
For enterprise-grade, globally distributed rate limiting using Cloudflare's native Rate Limiting:
import { cloudflareRateLimiter } from "@jellyfungus/hono-rate-limiter";
type Bindings = { RATE_LIMITER: RateLimitBinding };
const app = new Hono<{ Bindings: Bindings }>();
app.use(
cloudflareRateLimiter({
binding: (c) => c.env.RATE_LIMITER,
keyGenerator: (c) => c.req.header("cf-connecting-ip") ?? "unknown",
}),
);WebSocket Rate Limiting
Rate limit WebSocket message frequency:
import { Hono } from "hono";
import { createBunWebSocket } from "hono/bun";
import { webSocketLimiter } from "@jellyfungus/hono-rate-limiter/websocket";
const { upgradeWebSocket, websocket } = createBunWebSocket();
const wsLimiter = webSocketLimiter({
limit: 100,
windowMs: 60_000,
keyGenerator: (c) => c.req.header("cf-connecting-ip") ?? "unknown",
});
app.get(
"/ws",
upgradeWebSocket(
wsLimiter((c) => ({
onMessage(event, ws) {
ws.send("Hello!");
},
})),
),
);Note: WebSocket rate limiting is compatible with Bun, Deno, and Cloudflare Workers. The implementation handles platform differences in event handler signatures.
Cloudflare Workers WebSocket
import { Hono } from "hono";
import { upgradeWebSocket } from "hono/cloudflare-workers";
import { webSocketLimiter } from "@jellyfungus/hono-rate-limiter/websocket";
const app = new Hono();
const wsLimiter = webSocketLimiter({
limit: 100,
windowMs: 60_000,
keyGenerator: (c) => c.req.header("cf-connecting-ip") ?? "unknown",
});
app.get(
"/ws",
upgradeWebSocket(
wsLimiter((c) => ({
onMessage(event, ws) {
ws.send(`Echo: ${event.data}`);
},
})),
),
);
export default app;Deno WebSocket
import { Hono } from "hono";
import { upgradeWebSocket } from "hono/deno";
import { webSocketLimiter } from "@jellyfungus/hono-rate-limiter/websocket";
const app = new Hono();
const wsLimiter = webSocketLimiter({
limit: 100,
windowMs: 60_000,
keyGenerator: (c) => {
// Deno provides connection info
const addr = c.env?.remoteAddr;
return addr?.hostname ?? "unknown";
},
});
app.get(
"/ws",
upgradeWebSocket(
wsLimiter((c) => ({
onMessage(event, ws) {
ws.send(`Echo: ${event.data}`);
},
})),
),
);
Deno.serve(app.fetch);Dynamic Limits
Different limits for different users:
app.use(
rateLimiter({
limit: async (c) => {
const user = c.get("user");
if (user?.tier === "enterprise") return 10000;
if (user?.tier === "pro") return 1000;
return 100;
},
}),
);Custom Key Generator
Rate limit by API key, user ID, or any identifier:
app.use(
rateLimiter({
keyGenerator: (c) => {
return c.req.header("x-api-key") ?? "anonymous";
},
}),
);Skip Certain Requests
Bypass rate limiting for specific routes:
app.use(
rateLimiter({
skip: (c) => {
return c.req.path === "/health" || c.req.path === "/metrics";
},
}),
);Access Rate Limit Info
Get rate limit information in your handlers:
app.get("/api/status", (c) => {
const info = c.get("rateLimit");
return c.json({
limit: info?.limit,
remaining: info?.remaining,
resetAt: info?.reset,
});
});Dry-Run Mode
Monitor rate limits without blocking requests. Useful for testing limits before enforcing them:
app.use(
rateLimiter({
limit: 100,
windowMs: 60_000,
dryRun: true, // Allows all requests, but still tracks and sets headers
onRateLimited: (c, info) => {
// Log when limits would be exceeded
console.log(`Would rate limit: ${c.req.url}`, info);
},
}),
);Manual Store Control
Access the store directly for manual operations:
app.post("/api/admin/reset-limit/:userId", async (c) => {
const store = c.get("rateLimitStore");
const userId = c.req.param("userId");
await store?.resetKey(userId);
// Or reset all: await store?.resetAll();
return c.json({ success: true });
});Custom Handler
Customize the rate limit exceeded response:
app.use(
rateLimiter({
handler: (c, info) => {
return c.json(
{
error: "Rate limit exceeded",
retryAfter: Math.ceil((info.reset - Date.now()) / 1000),
},
429,
);
},
}),
);Response Headers
The middleware supports multiple header formats. Choose based on your needs:
"legacy" (default)
The widely-used X-RateLimit-* headers. Used by GitHub, Twitter, and most APIs:
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 45
X-RateLimit-Reset: 1640000000- Reset is a Unix timestamp (seconds since epoch)
- Best for broad client compatibility
"draft-6"
IETF draft-06 format with individual RateLimit-* headers:
RateLimit-Policy: 60;w=60
RateLimit-Limit: 60
RateLimit-Remaining: 45
RateLimit-Reset: 30- Reset is seconds until the window resets (not a timestamp)
- Compatible with express-rate-limit's
draft-6option
"draft-7"
IETF draft-07 format with a combined RateLimit header:
RateLimit-Policy: 60;w=60
RateLimit: limit=60, remaining=45, reset=30- Uses comma-separated values in a single header
- Compatible with express-rate-limit's
draft-7option
"standard" (IETF draft-08+)
Current IETF draft-ietf-httpapi-ratelimit-headers specification using structured field values (RFC 9651):
RateLimit-Policy: "default";q=60;w=60
RateLimit: "default";r=45;t=30Parameters:
| Header | Param | Meaning |
| ---------------- | ----- | -------------------------- |
| RateLimit-Policy | q | quota (max requests) |
| RateLimit-Policy | w | window (seconds) |
| RateLimit-Policy | qu | quota unit (optional) |
| RateLimit | r | remaining requests |
| RateLimit | t | time until reset (seconds) |
Use the identifier option to customize the policy name:
rateLimiter({
headers: "standard",
identifier: "api-v1",
// Headers: RateLimit-Policy: "api-v1";q=60;w=60
});Use the quotaUnit option for non-request-based limits:
rateLimiter({
headers: "standard",
identifier: "bandwidth",
quotaUnit: "content-bytes",
// Headers: RateLimit-Policy: "bandwidth";q=1000000;w=60;qu="content-bytes"
});Disabled
Use headers: false to disable all rate limit headers.
Retry-After Header
When a request is rate limited (429 response), the Retry-After header is always included with the number of seconds until the client can retry.
Security Considerations
IP Header Spoofing
The default key generator uses request headers (X-Forwarded-For, X-Real-IP, CF-Connecting-IP) to identify clients. These headers can be spoofed by malicious clients if your application is not behind a trusted reverse proxy.
Only trust these headers when your app is behind a trusted proxy (nginx, Cloudflare, AWS ALB, etc.) that overwrites them.
For untrusted environments or additional security, use authenticated identifiers:
app.use(
rateLimiter({
keyGenerator: (c) => {
// Use authenticated user ID instead of IP
const user = c.get("user");
return user?.id ?? "anonymous";
},
}),
);Error Handling
By default, the rate limiter uses a "fail-open" strategy: if the store (Redis, KV, etc.) is unavailable, requests are allowed through. This prioritizes availability over strict rate limiting.
For stricter security, use "fail-closed":
app.use(
rateLimiter({
onStoreError: "deny", // Block requests when store fails
}),
);Or use a custom handler:
app.use(
rateLimiter({
onStoreError: (error, c) => {
console.error("Rate limiter error:", error);
return false; // false = deny, true = allow
},
}),
);License
MIT
