npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@jellyfungus/hono-rate-limiter

v0.6.0

Published

Production-ready rate limiting middleware for Hono web framework

Readme

@jellyfungus/hono-rate-limiter

Production-ready rate limiting middleware for Hono web framework.

npm version CI

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-limiter

Quick 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 automatically

Redis 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-6 option

"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-7 option

"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=30

Parameters:

| 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