express-rate-limit vs rate-limiter-flexible

Choosing between express-rate-limit and rate-limiter-flexible is the library decision every Node.js team faces once a single in-memory limiter stops being enough. This comparison sits under the Express.js Rate Limit Middleware guide, and it turns on what you actually need: a drop-in HTTP middleware with sane defaults, or a lower-level consume/block primitive that limits anything — logins, queue jobs, websocket messages — across many store backends. Pick express-rate-limit for the common “N requests per window on a route” case; reach for rate-limiter-flexible when you need block-duration penalties, true sliding behavior, multiple stores, or to rate-limit non-HTTP work.

express-rate-limit as HTTP middleware versus rate-limiter-flexible as a primitive express-rate-limit wraps an HTTP route with a store, while rate-limiter-flexible exposes a consume and block API usable for HTTP, logins, and queue jobs across many stores. express-rate-limit rate-limiter-flexible HTTP route middleware store (memory / Redis) fixed window, 429 + headers batteries included HTTP route login attempts queue jobs consume() block() Redis / Mongo / memory block-duration, insurance

Decision matrix

Criterion express-rate-limit rate-limiter-flexible
Primary shape Express/Connect HTTP middleware Library primitive (consume/block/penalty/reward)
Default algorithm Fixed window (counter + TTL) Fixed window, with sliding/token-style options and atomic Redis scripts
Store backends Memory; Redis/Memcached/Mongo via adapter packages Redis, Memcached, MongoDB, MySQL/Postgres, Cluster, plus in-memory
Block-duration penalty No native long block beyond the window Yes — blockDuration keeps a key blocked after exhaustion
Non-HTTP usage No — coupled to req/res Yes — limit logins, jobs, sockets, anything keyed
Insurance / fallback store Manual (skipFailedRequests, custom handler) Built-in insuranceLimiter in-memory fallback on store outage
Headers (RateLimit-*) Built-in (standardHeaders) Manual — you read consume() result and set headers
Atomicity Store-dependent (Redis store uses scripts) Atomic Redis Lua across all consume/penalty operations
Best fit Standard “N per window per route” with minimal code Penalty-based abuse control, multi-resource limits, multiple stores

Selection rules:

  • Use express-rate-limit when you want the smallest amount of code to put a fixed window on a route, get 429 + RateLimit-* headers for free, and your store is memory or Redis.
  • Use rate-limiter-flexible when you need a blockDuration (e.g. lock an account for 15 min after 5 failed logins), a built-in in-memory fallback when the store is down, a non-Redis backend like Mongo or Postgres, or to rate-limit work that is not an HTTP request.
  • Use both: express-rate-limit for coarse per-route HTTP quotas and rate-limiter-flexible for targeted abuse penalties (brute-force login, scraping) where block-duration matters.
What each library is shaped for One library is a drop-in HTTP middleware with sensible defaults, the other is a general consume-and-block primitive that limits anything, so the choice follows what you need to limit. Middleware or primitive HTTP middleware minimal setup headers included routes only general primitive any unit of work queues and blocks more assembly required both together middleware for routes primitive for jobs common in practice pick by what you are limiting, not by benchmark numbers

Step-by-step implementation

express-rate-limit — route middleware

typescript
// Minimal HTTP middleware: fixed window, 429 + RateLimit-* headers for free.
import rateLimit from "express-rate-limit";
import { RedisStore } from "rate-limit-redis";
import { createClient } from "redis";

const redis = createClient({ url: process.env.REDIS_URL });
await redis.connect();

export const apiLimiter = rateLimit({
  store: new RedisStore({ sendCommand: (...args) => redis.sendCommand(args) }),
  windowMs: 60_000,            // 1-minute fixed window
  max: 100,                    // 100 requests/key/window
  standardHeaders: true,       // emit IETF RateLimit-* headers
  legacyHeaders: false,
  keyGenerator: (req) => (req.headers["x-api-key"] as string) ?? req.ip,
});
// app.use("/api", apiLimiter);

rate-limiter-flexible — consume/block primitive

typescript
// Lower-level: consume points, apply a block-duration penalty, set headers yourself.
import { RateLimiterRedis } from "rate-limiter-flexible";
import Redis from "ioredis";
import type { Request, Response, NextFunction } from "express";

const redis = new Redis(process.env.REDIS_URL!);

const limiter = new RateLimiterRedis({
  storeClient: redis,
  keyPrefix: "rl",
  points: 100,          // 100 requests
  duration: 60,         // per 60 seconds
  blockDuration: 900,   // once exhausted, block this key for 15 minutes
  // falls back to an in-memory limiter if Redis is unreachable:
  insuranceLimiter: undefined, // set to a RateLimiterMemory instance in prod
});

export async function flexibleLimit(req: Request, res: Response, next: NextFunction) {
  const key = (req.headers["x-api-key"] as string) ?? req.ip!;
  try {
    const r = await limiter.consume(key, 1); // throws when no points remain
    res.setHeader("RateLimit-Limit", "100");
    res.setHeader("RateLimit-Remaining", String(r.remainingPoints));
    res.setHeader("RateLimit-Reset", String(Math.ceil(r.msBeforeNext / 1000)));
    next();
  } catch (rejRes: any) {
    // rejRes carries msBeforeNext even while the key is in its block window
    res.setHeader("Retry-After", String(Math.ceil(rejRes.msBeforeNext / 1000)));
    res.status(429).json({ error: "Too Many Requests" });
  }
}

Operator checklist

Gotchas & edge cases

  • express-rate-limit defaults to a fixed window. It permits a 2× burst across the window boundary. If that matters, use rate-limiter-flexible’s sliding options or a custom store.
  • rate-limiter-flexible.consume() rejects by throwing. The rejection is a RateLimiterRes, not an Error — destructure msBeforeNext/remainingPoints, do not assume it has a .message.
  • blockDuration outlives the window. A blocked key stays blocked for the full blockDuration even after the points window would have refilled — that is the point, but it surprises people testing locally.
  • Headers are manual with rate-limiter-flexible. You will not get RateLimit-* automatically; map consume() results to headers yourself or clients can’t back off intelligently.
  • Adapter version drift in express-rate-limit. rate-limit-redis v4 uses a named RedisStore export and sendCommand; older snippets using a default export will not compile.

Verification & testing

Confirm both libraries hold the aggregate limit across pods and that a block-duration actually blocks.

bash
# 50 concurrent clients, same key, 10s — expect ~100 accepted/min total, the rest 429.
hey -z 10s -c 50 -H "X-API-Key: acct_42" https://api.example.com/api/resource \
  | grep -E "Status code distribution" -A4
# For rate-limiter-flexible: after exhausting points, every call within blockDuration
# must keep returning 429 with a shrinking Retry-After, even if you pause.
Where the request time actually goes Both libraries perform one store round trip per decision, so the measurable difference between them is dominated by the network call rather than by library overhead. Comparing the two fairly library overhead microseconds effectively identical rarely decisive store round trip 0.3 to 2 ms identical for both dominates the decision a benchmark without the real store measures the wrong thing entirely

Frequently Asked Questions

Which library is faster?

Both are dominated by the Redis round-trip, not library overhead — expect the same order of magnitude. rate-limiter-flexible uses atomic Lua for all operations, and express-rate-limit with the Redis store does too, so per-request cost is comparable. Choose on features, not micro-benchmarks.

Can express-rate-limit block an account for 15 minutes after abuse?

Not natively — its counter resets at the end of windowMs. For a penalty that outlives the window you want rate-limiter-flexible's blockDuration, which keeps the key blocked after the points are exhausted regardless of refill.

Do I need Redis with rate-limiter-flexible?

No. It supports Redis, Memcached, MongoDB, MySQL/Postgres, Redis Cluster mode, and pure in-memory. The in-memory limiter is also what you wire as an insuranceLimiter so the app keeps limiting (approximately) when the shared store is unreachable.

Can I rate-limit non-HTTP work with these?

express-rate-limit is coupled to the Express request/response, so no. rate-limiter-flexible is a plain consume(key) primitive, so you can guard login attempts, queue jobs, websocket messages, or any keyed operation with the same limiter.

Whichever you adopt, wrap it. A thin internal module that exposes consume, headers, and a degradation policy keeps the choice reversible: swapping libraries later becomes one file rather than a change to every route in the application.

One practical tiebreaker: check which library your framework’s ecosystem already pulls in transitively. Running two limiter libraries in one process is not harmful, but it doubles the configuration surface and guarantees that a future maintainer tunes the wrong one.

Whichever you choose, verify the effective ceiling with a single-key load test against a deployment that has the production worker count. That measurement, not the library documentation, is what tells you whether the number you published is the number clients actually get.

Document the choice and its reason in the repository. Six months later the question “why do we use this one?” will be asked during an unrelated incident, and a one-paragraph answer in the code is worth more than an archaeology session through commit history.