Back to blog
Jun 2026·3 min readAI-generated

Building a Rate Limiter Middleware in Express

#javascript#nodejs#backend

An unprotected API endpoint is an invitation: one misbehaving client script, one retry loop without backoff, one scraper, and your server spends all its time serving the same handful of clients instead of everyone else. Rate limiting fixes that by capping how many requests a client can make in a given window — and you can build a solid one yourself in about 40 lines, no dependency required.

What "rate limiting" actually means

A rate limiter tracks requests per client (usually keyed by IP address or API key) and rejects requests once that client crosses a threshold within a time window — "100 requests per minute," for example. Once the window passes, the count resets and the client can make requests again.

The simplest correct implementation is the fixed window counter: for each client, keep a count and a window-start timestamp. Increment on each request; reset both when the window expires.

A minimal fixed-window limiter

function createRateLimiter({ windowMs, max }) {
  const clients = new Map();

  return function rateLimiter(req, res, next) {
    const key = req.ip;
    const now = Date.now();
    const entry = clients.get(key);

    if (!entry || now - entry.windowStart >= windowMs) {
      clients.set(key, { windowStart: now, count: 1 });
      return next();
    }

    if (entry.count >= max) {
      const retryAfterMs = windowMs - (now - entry.windowStart);
      res.setHeader('Retry-After', Math.ceil(retryAfterMs / 1000));
      return res.status(429).json({ error: 'Too many requests' });
    }

    entry.count += 1;
    next();
  };
}

Wiring it into an Express app:

const express = require('express');
const app = express();

const limiter = createRateLimiter({ windowMs: 60_000, max: 100 });
app.use('/api/', limiter);

app.get('/api/posts', (req, res) => {
  res.json({ posts: [] });
});

app.listen(3000);

Every client gets 100 requests per rolling 60-second window before seeing a 429 Too Many Requests, with a Retry-After header telling them when to come back.

The fixed-window problem: boundary bursts

Fixed windows have a well-known flaw: a client can make max requests in the last second of one window, then immediately make max more in the first second of the next window — 2x the intended rate, concentrated in a two-second burst right at the boundary.

window 1: [ ......................############]  <- 100 requests, last second
window 2: [############......................]  <- 100 more, first second

For most APIs this doesn't matter much. If it does, the fix is a sliding window log: instead of one counter per client, store the timestamp of every request in the window and count how many fall within now - windowMs.

function createSlidingWindowLimiter({ windowMs, max }) {
  const clients = new Map();

  return function rateLimiter(req, res, next) {
    const key = req.ip;
    const now = Date.now();
    const timestamps = clients.get(key) ?? [];

    const windowStart = now - windowMs;
    const recent = timestamps.filter((t) => t > windowStart);

    if (recent.length >= max) {
      clients.set(key, recent);
      res.setHeader('Retry-After', Math.ceil((recent[0] - windowStart) / 1000));
      return res.status(429).json({ error: 'Too many requests' });
    }

    recent.push(now);
    clients.set(key, recent);
    next();
  };
}

This is exact but costs more memory (one timestamp per request per client, not one counter). For high-traffic APIs, a hybrid — the sliding window counter, which weights the previous window's count by how much of it overlaps the current one — gets you close to exact behavior at fixed-window memory cost. It's more math than code, which is why most teams reach for a library or a proxy-level limiter (nginx's limit_req, or a Redis-backed one) once they need it at scale.

Choosing a rate-limiting key

req.ip is the obvious key, but it breaks down behind a shared NAT (an office full of engineers sharing one public IP) or a proxy that doesn't forward the real client address. Two adjustments matter in practice:

  • Trust your proxy correctly. If you're behind nginx or a load balancer, req.ip will be the proxy's address unless you configure Express to trust it: app.set('trust proxy', 1) and read req.ip from X-Forwarded-For. Get this wrong and every client gets rate-limited as one.
  • Prefer an API key over an IP where you have one. Authenticated endpoints should rate-limit per API key or user ID, not per IP — it's the actual identity you care about, and it survives IP changes and shared networks.

Cleaning up stale entries

The Map in both implementations above grows forever unless you evict old entries — a slow memory leak in a long-running process. A simple periodic sweep handles it:

setInterval(() => {
  const cutoff = Date.now() - windowMs;
  for (const [key, entry] of clients) {
    if (entry.windowStart < cutoff) clients.delete(key);
  }
}, windowMs).unref();

The .unref() matters in Node: it tells the event loop not to keep the process alive just for this timer, so it doesn't interfere with graceful shutdown.

When to reach for a library instead

express-rate-limit implements all of the above (plus IPv6 handling and pluggable stores) and is the right default for a single-process app. Once you run multiple instances behind a load balancer, an in-memory Map stops working — each instance has its own counters, so a client can get max requests per instance instead of max total. At that point you need a shared store, and express-rate-limit supports a Redis-backed one for exactly this reason.

Takeaway

A rate limiter is fundamentally a counter with a time window attached to it — everything else is refinement. Start with a fixed window; it's simple, cheap, and correct enough for most APIs. Move to a sliding window only once boundary bursts are an actual, measured problem, and move to a shared store the moment you run more than one server process.

NewerFeature Toggling with Environment-Based ConfigOlderUnderstanding Content Security Policy (CSP)