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

limitly

v3.1.0

Published

Express-rate-limit, but distributed, Redis-powered, and production ready.

Readme

limitly

Distributed, Redis-powered rate limiting for Express, Fastify, Hono, Koa, Bun, and NestJS.

Express-rate-limit, but distributed, Redis-powered, and production ready.

Install

npm install limitly ioredis

# Memcached backend
npm install limitly memcached

# OpenTelemetry
npm install limitly ioredis @opentelemetry/api

# Prometheus
npm install limitly ioredis prom-client

# NestJS
npm install limitly ioredis @nestjs/common @nestjs/core

Framework-specific subpath imports are available for tree-shaking:

import { createExpressMiddleware } from "limitly/express";
import { createFastifyPlugin } from "limitly/fastify";
import { createHonoMiddleware } from "limitly/hono";
import { createKoaMiddleware } from "limitly/koa";
import { createBunMiddleware } from "limitly/bun";

Quick Start

Express

import express from "express";
import Redis from "ioredis";
import { createLimiter } from "limitly";

const app = express();
const redis = new Redis();
const limiter = createLimiter({ redis });

app.use(limiter.middleware({ key: (req) => req.ip }));

Fastify

import Fastify from "fastify";
import Redis from "ioredis";
import { createLimiter } from "limitly";

const fastify = Fastify();
const limiter = createLimiter({ redis: new Redis() });

await fastify.register(limiter.fastifyPlugin, {
  algorithm: "token-bucket",
  capacity: 50,
  refillRate: 10,
  key: (req) => req.ip,
});

Hono

import { Hono } from "hono";
import Redis from "ioredis";
import { createLimiter } from "limitly";

const app = new Hono();
const limiter = createLimiter({ redis: new Redis() });

app.use("*", limiter.honoMiddleware({ key: (c) => c.req.header("x-api-key") }));

Koa

import Koa from "koa";
import Redis from "ioredis";
import { createLimiter } from "limitly";

const app = new Koa();
const limiter = createLimiter({ redis: new Redis() });

app.use(limiter.koaMiddleware({ key: (ctx) => ctx.ip }));

Bun

import Redis from "ioredis";
import { composeBunHandler, createLimiter } from "limitly";

const limiter = createLimiter({ redis: new Redis() });

const rateLimit = limiter.bunMiddleware({
  key: (req) => req.headers.get("x-api-key"),
});

const fetch = composeBunHandler(
  [rateLimit],
  () => Response.json({ message: "Hello Bun!" })
);

Bun.serve({ port: 3000, fetch });

NestJS

import { Module } from "@nestjs/common";
import { APP_GUARD } from "@nestjs/core";
import Redis from "ioredis";
import { createLimiter } from "limitly";
import { RateLimit } from "limitly/nest";

const limiter = createLimiter({ redis: new Redis() });
const NestGuard = limiter.nestGuard({
  limit: 100,
  window: 60,
  key: (req) => req.ip,
});

@Module({
  providers: [{ provide: APP_GUARD, useClass: NestGuard }],
})
export class AppModule {}

Per-route limits with @RateLimit():

import { Controller, Get } from "@nestjs/common";
import { RateLimit } from "limitly/nest";

@Controller("api")
export class ApiController {
  @Get()
  @RateLimit({ limit: 10, window: 60 })
  findAll() {
    return { ok: true };
  }
}

Module helper (like redisLimitPlugin for Fastify):

import { limitlyNestModule } from "limitly/nest";

@Module({
  imports: [
    limitlyNestModule({
      limiter,
      algorithm: "token-bucket",
      capacity: 50,
      refillRate: 10,
      global: true,
    }),
  ],
})
export class AppModule {}

Default Algorithm

If you omit algorithm options, limitly uses GCRA with limit: 100 and window: 60:

const limiter = createLimiter({ redis: new Redis() });

app.use(limiter.middleware({ key: (req) => req.ip }));

// equivalent to:
app.use(
  limiter.middleware({
    algorithm: "gcra",
    limit: 100,
    window: 60,
    key: (req) => req.ip,
  })
);

Set limiter-wide defaults:

const limiter = createLimiter({
  redis: new Redis(),
  default: { limit: 50, window: 30 },
});

app.use(limiter.middleware({ key: (req) => req.ip }));

Programmatic Checks

Use limiter.check() outside middleware — useful for login guards, background jobs, or custom response handling:

const result = await limiter.check(req.ip ?? "unknown", {
  limit: 5,
  window: 10,
});

if (!result.allowed) {
  return res.status(429).json({
    error: "Too many attempts",
    retryAfter: result.retryAfter,
  });
}

check() respects limiter-wide defaults, failOpen, and global onMetrics hooks.

Algorithms

GCRA (default)

Generic Cell Rate Algorithm — smooth rate limiting with controlled bursts. Uses a single TAT (theoretical arrival time) per key, so it's memory-efficient compared to sliding window:

limiter.middleware({
  limit: 100,
  window: 60, // seconds — algorithm defaults to gcra
  key: (req) => req.ip,
});

GCRA enforces an average rate of limit / window while allowing short bursts up to limit. Ideal when you want token-bucket-like behavior with predictable storage costs.

Sliding Window

Uses Redis Sorted Sets (or Memcached counters) for rate limiting over a rolling time window.

limiter.middleware({
  algorithm: "sliding-window",
  limit: 100,
  window: 60, // seconds
});

Token Bucket

Supports burst traffic with configurable refill rate.

limiter.middleware({
  algorithm: "token-bucket",
  capacity: 100,
  refillRate: 10, // tokens per second
});

Concurrency

Limits simultaneous in-flight requests per key. Slots are acquired on entry and released when the response finishes (middleware handles this automatically):

limiter.middleware({
  algorithm: "concurrency",
  limit: 10, // max concurrent requests
  ttl: 300, // lease TTL in seconds for stale slot cleanup
  key: (req) => req.ip,
});

For manual acquire/release outside middleware:

const acquired = await limiter.acquire("job-42", {
  algorithm: "concurrency",
  limit: 5,
  ttl: 120,
});

if (!acquired.allowed) {
  throw new Error("Too many concurrent jobs");
}

try {
  await runJob();
} finally {
  await limiter.release("job-42", acquired.slotId!, {
    algorithm: "concurrency",
    limit: 5,
    ttl: 120,
  });
}

Storage keys use the cc: prefix: {keyPrefix}:cc:{id}.

Storage Backends

limitly supports Redis, Valkey, DragonflyDB (Redis-compatible), and Memcached.

Redis / Valkey / DragonflyDB

Redis-compatible backends use atomic Lua scripts (sorted sets + hashes). Connect with ioredis:

import Redis from "ioredis";

// Redis
createLimiter({ redis: new Redis() });

// Valkey
createLimiter({ store: "valkey", redis: "redis://localhost:6379" });

// DragonflyDB
createLimiter({ store: "dragonfly", redis: { host: "localhost", port: 6379 } });

// Cluster (auto-pipelining, script warmup, master reads)
createLimiter({
  store: "redis",
  redis: {
    nodes: [
      { host: "127.0.0.1", port: 7000 },
      { host: "127.0.0.1", port: 7001 },
    ],
    options: {
      redisOptions: { password: "secret" },
    },
  },
});

// Optional: pin all keys to one slot (use only when you need co-location)
createLimiter({
  redis: { nodes: [{ host: "127.0.0.1", port: 7000 }] },
  hashTag: "limitly",
});

Cluster optimizations built into limitly:

  • ioredis defineCommand — Lua scripts are registered cluster-aware, avoiding per-node NOSCRIPT fallbacks
  • Script warmup — scripts are preloaded on all master nodes at startup (disable with warmupScripts: false)
  • Auto-pipelining — enabled by default for higher throughput under concurrent load
  • Hash tags — optional hashTag for slot pinning; leave unset to spread keys across slots (recommended)
  • Master readsscaleReads: "master" by default so checks always hit the authoritative node

Memcached

Memcached uses counter-based sliding window and CAS token bucket (no Lua required):

import Memcached from "memcached";

createLimiter({
  store: "memcached",
  memcached: "localhost:11211",
});

// Or pass an existing client
createLimiter({ memcached: new Memcached(["localhost:11211"]) });

Memcached sliding window uses a weighted two-window counter (high accuracy, no sorted sets). Token bucket uses gets/cas for atomic updates.

Key Extraction

The key option identifies who is being rate limited (per request):

// IP-based
key: (req) => req.ip;

// API Key
key: (req) => req.headers["x-api-key"];

// User ID
key: (req) => req.user.id;

Storage Key Prefix

The keyPrefix option controls where counters are stored in Redis/Memcached. Default is limitly:

createLimiter({ redis: new Redis() });
// stores keys like: limitly:sw:203.0.113.1

createLimiter({
  redis: new Redis(),
  keyPrefix: "myapp:prod",
});
// stores keys like: myapp:prod:sw:203.0.113.1

Key format:

{keyPrefix}:sw:{id}   — sliding window
{keyPrefix}:tb:{id}   — token bucket
{keyPrefix}:gcra:{id} — GCRA
{keyPrefix}:cc:{id}   — concurrency

Response Headers

Standard rate limit headers are set by default:

X-RateLimit-Limit: 100
X-RateLimit-Remaining: 45
X-RateLimit-Reset: 1710000000
Retry-After: 15

Disable with headers: false.

Retry-After Jitter

When many clients hit a limit at once, they can all retry at the same Retry-After and cause a thundering herd. Add jitter to spread retries out:

limiter.middleware({
  limit: 100,
  window: 60,
  retryAfterJitter: 5, // add 0-5 extra seconds (default: 0)
});

Jitter is applied to blocked responses only. Metrics still report the underlying store result.

Custom Limit Response

limiter.middleware({
  onLimitReached(req, res) {
    res.status(429).json({ code: "RATE_LIMITED" });
  },
});

Metrics

Emit observability events via the onMetrics hook. Set it globally on the limiter or per middleware/route:

const limiter = createLimiter({
  redis: new Redis(),
  onMetrics: (event) => {
    console.log(event.type, event.key, `${event.durationMs.toFixed(2)}ms`);
  },
});

// Per-route override
limiter.middleware({
  onMetrics: (event) => metrics.increment(`ratelimit.${event.type}`),
});

Event types:

| Type | When | |------|------| | allowed | Request passed the rate limit check | | blocked | Request exceeded the limit | | error | Store operation failed | | fail_open | Store failed but failOpen: true allowed traffic through |

Each event includes key, algorithm, durationMs, and optionally store, context (the request object), and result or error depending on type.

Multiple hooks are supported:

onMetrics: [logToConsole, sendToDatadog]

limiter.check() and all framework middleware use the same metrics pipeline.

OpenTelemetry

Use the limitly/otel helpers to emit OTEL metrics and traces. Only @opentelemetry/api is required — bring your own SDK/exporter:

import Redis from "ioredis";
import { createLimiter } from "limitly";
import { createOpenTelemetryInstrumentation } from "limitly/otel";

const otel = createOpenTelemetryInstrumentation();

const limiter = createLimiter({
  redis: new Redis(),
  tracer: otel.tracer,
  onMetrics: otel.onMetrics,
});

app.use(limiter.middleware({ key: (req) => req.ip }));

createOpenTelemetryInstrumentation() returns:

  • tracer — creates limitly.check spans with limitly.algorithm, limitly.store, limitly.outcome, limitly.limit, and limitly.remaining attributes
  • onMetrics — records limitly.check.total (counter) and limitly.check.duration (histogram, ms)

Use the pieces independently when needed:

import {
  createOpenTelemetryMetricsHook,
  createOpenTelemetryTracer,
} from "limitly/otel";

const limiter = createLimiter({
  redis: new Redis(),
  tracer: createOpenTelemetryTracer(),
  onMetrics: createOpenTelemetryMetricsHook({ includeKey: false }),
});

Combine with custom hooks:

onMetrics: [otel.onMetrics, customHook]

Prometheus

Use limitly/prometheus to expose rate limit metrics for scraping:

import express from "express";
import Redis from "ioredis";
import { createLimiter } from "limitly";
import {
  createPrometheusExporter,
  createPrometheusHandler,
} from "limitly/prometheus";

const app = express();
const prometheus = createPrometheusExporter();

const limiter = createLimiter({
  redis: new Redis(),
  onMetrics: prometheus.onMetrics,
});

app.use(limiter.middleware({ key: (req) => req.ip }));
app.get("/metrics", createPrometheusHandler(prometheus));

Metrics exposed:

| Metric | Type | Labels | |--------|------|--------| | limitly_check_total | Counter | algorithm, outcome, store | | limitly_check_duration_seconds | Histogram | algorithm, outcome, store |

outcome is one of allowed, blocked, error, or fail_open.

Options:

| Option | Default | Description | |--------|---------|-------------| | register | new Registry | Prometheus registry | | prefix | "limitly" | Metric name prefix | | includeKey | false | Add rate limit key as a label (high cardinality) | | durationBuckets | [0.001, 0.005, 0.01, …, 1] | Histogram buckets (seconds) |

Use a shared registry with your existing metrics:

import { Registry } from "prom-client";

const register = new Registry();
const prometheus = createPrometheusExporter({ register });

onMetrics: [prometheus.onMetrics, otherHook];

Scrape manually without HTTP middleware:

const body = await prometheus.getMetrics();

Use createPrometheusMetricsHook when you only need the onMetrics hook without the HTTP handler:

import { createPrometheusMetricsHook } from "limitly/prometheus";

const limiter = createLimiter({
  redis: new Redis(),
  onMetrics: createPrometheusMetricsHook({ prefix: "api" }),
});

Fail Open / Closed

When Redis is unavailable:

createLimiter({
  redis: new Redis(),
  failOpen: true, // allow traffic (default)
  // failOpen: false, // block with 503
});

Extensibility

Implement custom algorithms with the RateLimitStrategy interface:

interface RateLimitStrategy {
  consume(key: string): Promise<RateLimitResult>;
  release?(key: string, slotId: string): Promise<void>;
}

release is optional — only needed for concurrency-style strategies that hold a slot until the response finishes.

Performance

  • Atomic operations via Lua scripts (EVALSHA)
  • Automatic key cleanup with EXPIRE
  • P95 < 5ms per check (excluding network latency)
  • 20,000+ checks/sec on a single Redis node

License

MIT