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.

Step-by-step implementation

express-rate-limit — route middleware

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

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

  • Library choice driven by need: middleware-and-headers (express-rate-limit) vs penalty/primitive (rate-limiter-flexible
  • If using rate-limiter-flexible, an insuranceLimiter
  • If using express-rate-limit, explicit fail-open vs fail-closed behavior set in a custom handler
  • blockDuration chosen deliberately for abuse cases (e.g. 5 failed logins →
  • RateLimit-* / Retry-After

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.

# 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.

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.