easy-rate-limit
v1.0.0
Published
Simple Express rate-limiting middleware with fixed-window counter
Maintainers
Readme
easy-rate-limit
Simple, tiny Express rate-limiting middleware with zero setup.
Fixed-window counter per IP (or any key you choose). Includes an in-memory store, X-RateLimit-* headers, custom handlers, and pluggable stores for Redis etc. Written in TypeScript.
Installation
npm install easy-rate-limitRequirements: Node >= 18, express >= 5.
Quick start
import express from "express";
import limiter from "easy-rate-limit";
const app = express();
// 100 requests per minute per IP
app.use(limiter({ max: 100 }));
app.get("/", (req, res) => res.send("Hello!"));
app.listen(3000);That's it — over-limit requests get 429 with { error, retryAfter }.
Features
- 🚀 One-line setup, sensible defaults
- 🔑 Key by IP, user id, API key — sync or async
- ⏭️
skip()health checks or allowlists - 📦
X-RateLimit-Limit / Remaining / Reset+Retry-Afterheaders - 🧩 Custom
handlerandonLimitReachedhooks - 💾 Pluggable
Store(Redis-ready),MemoryStorebuilt in - 📘 Full TypeScript types included
Why choose easy-rate-limit?
| If you want… | easy-rate-limit | express-rate-limit |
| ----------------------------------------- | ------------ | -------------------- |
| One-line setup, zero config | ✅ | ✅ |
| Tiny API you can read in 5 minutes | ✅ | ❌ (many options, plugins) |
| Async keyGenerator / skip / handler | ✅ | ⚠️ (mostly sync) |
| Fail-fast on bad config (max, windowMs, store) | ✅ | ⚠️ (some cases silent) |
| Store errors never crash your app (next(err)) | ✅ | ✅ |
| Timer unref'd so it never keeps Node alive | ✅ | ✅ |
| Redis store included (RedisStore) | ✅ | ✅ (via plugin) |
| MongoDB store included (MongoStore) | ✅ | ❌ |
| Distributed / multi-instance ready | ✅ (Redis/Mongo) | ✅ |
Pick easy-rate-limit when: you want the smallest thing that works — a side project, internal tool, or single-instance API where app.use(limiter({ max: 100 })) is all you need. Scale to Redis/Mongo later without changing your middleware code.
Pick express-rate-limit when: you need its larger ecosystem (Memcached/Prisma/Cloudflare adapters, sliding windows).
How much traffic can it handle?
| Setup | Good for | Notes |
| ----- | -------- | ----- |
| MemoryStore (default) | ~1k–10k req/s on one Node instance; small–medium single-instance apps | Counters live in process memory: lost on restart, not shared between instances. Each key is one Map entry, so size it by unique clients × windows. |
| RedisStore | Horizontal scale: add app instances freely; Redis handles 50k–100k ops/s on modest hardware | All instances share counters. Needs a reachable Redis. Increment is atomic (INCR); one extra PTTL read per request. |
| MongoStore | Medium multi-instance apps already running MongoDB | One findOne + one updateOne per request; create the resetAt index (done automatically unless ensureIndex: false). Slower than Redis under very high load. |
Rule of thumb: start with MemoryStore for one server; switch to RedisStore the moment you run 2+ instances or need counters to survive restarts. The middleware code doesn't change — only the store option.
Usage
Basic
import limiter from "easy-rate-limit";
app.use(
limiter({
windowMs: 60_000, // 1 minute
max: 100, // 100 requests per window per key
}),
);Login route (strict limit + manual reset)
import { ApiLimiter } from "easy-rate-limit";
const loginLimiter = new ApiLimiter({
windowMs: 15 * 60 * 1000,
max: 5,
message: "Too many login attempts, try again later.",
});
app.use("/login", loginLimiter.middleware());
// e.g. after successful login, or in an admin endpoint:
await loginLimiter.reset("1.2.3.4"); // one client
await loginLimiter.reset(); // everyonePer-user keys, skip health checks, custom response
app.use(
limiter({
windowMs: 60_000,
max: 10,
keyGenerator: (req) => req.headers["x-user-id"] as string,
skip: (req) => req.path === "/health",
handler: (req, res) => {
res.status(429).json({ custom: true });
},
onLimitReached: (req, key) => console.warn(`rate limited: ${key}`),
}),
);Options
| Option | Type | Default |
| ---------------- | -------------------------------------- | ---------------------------------------------- |
| windowMs | number (positive) | 60000 |
| max | number (positive integer) | 100 |
| message | string | "Too many requests, please try again later." |
| statusCode | number (400–599) | 429 |
| headers | boolean | true |
| keyGenerator | (req) => string \| Promise<string> | req.ip ?? socket.remoteAddress ?? "unknown" |
| skip | (req) => boolean \| Promise<boolean> | () => false |
| handler | (req, res, next) => void \| Promise<void> | 429 JSON { error, retryAfter } |
| store | Store | MemoryStore |
| onLimitReached | (req, key) => void \| Promise<void> | undefined |
Invalid windowMs, max, statusCode, or store throw at construction time, so misconfigurations fail fast instead of silently misbehaving.
Response headers
When headers: true (default), every counted response includes:
X-RateLimit-Limit— themaxfor the windowX-RateLimit-Remaining— requests left in this windowX-RateLimit-Reset— window reset time (unix seconds)
Blocked responses also include Retry-After (seconds until retry).
limiter({ max: 10, headers: false }); // disable X-RateLimit-* headersDistributed stores (Redis / MongoDB)
Point every instance at the same Redis or MongoDB and counters are shared:
import { createClient } from "redis";
import limiter, { RedisStore } from "easy-rate-limit";
const redis = createClient({ url: "redis://localhost:6379" });
await redis.connect(); // works with node-redis or ioredis clients
app.use(limiter({ max: 100, store: new RedisStore(redis) }));import { MongoClient } from "mongodb";
import limiter, { MongoStore } from "easy-rate-limit";
const col = new MongoClient(process.env.MONGO_URL!)
.db("app")
.collection("rate_limits");
app.use(limiter({ max: 100, store: new MongoStore(col) }));Notes:
redis/mongodbare not hard dependencies — bring the client you already use; the stores accept your connected client/collection.- Neither store closes your connection:
shutdown()is a no-op on purpose. RedisStorekeys are prefixed (easy-rate-limit:by default, customizable);reset()without a key only touches that prefix.MongoStorecreates aresetAtindex automatically (opt out withnew MongoStore(col, { ensureIndex: false })).
Custom store
Anything implementing Store works:
import type { Store, HitResult } from "easy-rate-limit";
class MyStore implements Store {
async increment(key: string, windowMs: number): Promise<HitResult> { /* ... */ }
async get(key: string): Promise<HitResult | null> { /* ... */ }
async reset(key?: string): Promise<void> { /* ... */ }
}
limiter({ store: new MyStore() });API reference
| Export | Description |
| ------------------ | ---------------------------------------------------------------- |
| limiter(options?) (default) | Returns an Express middleware directly |
| ApiLimiter | Class — .middleware(), .reset(key?), .options |
| MemoryStore | Single-instance store — .increment(), .get(), .reset(), .shutdown() |
| RedisStore | Distributed store for node-redis/ioredis clients (prefix option) |
| MongoStore | Distributed store for MongoDB collections (ensureIndex option) |
| LimiterError, InvalidStoreError | Error classes |
| Types | LimiterOptions, Store, HitResult |
import limiter, { ApiLimiter, MemoryStore, RedisStore, MongoStore } from "easy-rate-limit";
import type { LimiterOptions, Store, HitResult } from "easy-rate-limit";Note on
req.ip: behind a proxy, setapp.set("trust proxy", 1)so Express populatesreq.ipcorrectly.
Development
npm run build # compile src/ → dist/
npm test # jest suite in tests/License
MIT — see LICENSE.
