How to Build a REST API With Rate Limiting in Node.js

Unprotected APIs don't survive contact with the internet. Whether it's a competitor scraping your endpoints, a misconfigured mobile client hammering your server in a retry loop, or a genuine DDoS attempt, the question is never if abuse will happen — it's when. Rate limiting is your first line of defence, and most tutorials implement it wrong.

The typical approach — slapping express-rate-limit with an in-memory store onto an Express app — works fine on a single server in a development environment. In production, behind a load balancer with three Node.js instances, those in-memory counters are siloed per process. A user can hit your limit three times over simply by bouncing between instances. Worse, a hard "block after N requests" strategy is brutal for mobile users in West Africa who may retry aggressively because of packet loss and dropped connections.

This guide builds a proper solution: a token-bucket rate limiter backed by Redis, implemented from first principles in Node.js.


Why Token Bucket Over Fixed Window

Fixed-window counters are the simplest approach: count requests in a 60-second window, block when you hit the ceiling. The problem is the boundary burst — a user can fire 100 requests at 11:59 and another 100 at 12:00 and you've absorbed 200 requests in two seconds.

The token bucket model fixes this:

  • Each user starts with a bucket of N tokens.
  • Every request consumes one token.
  • Tokens replenish at a steady rate (e.g., 10 tokens per second).
  • If the bucket is empty, the request is rejected with a 429 Too Many Requests.

This smooths out bursts while still allowing short legitimate spikes — which is exactly what a mobile app on a 3G connection needs when it reconnects after a network drop and flushes a queue of pending API calls.


Project Setup

npm init -y
npm install express ioredis

You'll need a running Redis instance. For local development, docker run -d -p 6379:6379 redis:alpine is sufficient. In production, use a managed service like Redis Cloud, Upstash, or a self-hosted instance on your infrastructure.


Building the Token Bucket Middleware

The key insight is to run the entire token check-and-decrement as an atomic Lua script inside Redis. This eliminates the race condition you'd get from a read-then-write approach across concurrent requests.

// rateLimiter.js
const Redis = require('ioredis');
const redis = new Redis(process.env.REDIS_URL || 'redis://localhost:6379');

const BUCKET_CAPACITY = 20;      // max tokens
const REFILL_RATE = 10;          // tokens added per second
const REFILL_INTERVAL_MS = 1000; // refill every 1 second

const tokenBucketScript = `
  local key        = KEYS[1]
  local capacity   = tonumber(ARGV[1])
  local refillRate = tonumber(ARGV[2])
  local now        = tonumber(ARGV[3])

  local bucket = redis.call('HMGET', key, 'tokens', 'lastRefill')
  local tokens    = tonumber(bucket[1]) or capacity
  local lastRefill = tonumber(bucket[2]) or now

  local elapsed = math.max(0, now - lastRefill)
  local refilled = math.floor(elapsed / 1000 * refillRate)
  tokens = math.min(capacity, tokens + refilled)

  if tokens < 1 then
    redis.call('HSET', key, 'tokens', tokens, 'lastRefill', now)
    redis.call('EXPIRE', key, 3600)
    return 0
  end

  tokens = tokens - 1
  redis.call('HSET', key, 'tokens', tokens, 'lastRefill', now)
  redis.call('EXPIRE', key, 3600)
  return 1
`;

async function rateLimiter(req, res, next) {
  const identifier = req.ip || req.headers['x-forwarded-for'] || 'anonymous';
  const key = `rate:${identifier}`;
  const now = Date.now();

  const allowed = await redis.eval(
    tokenBucketScript, 1, key,
    BUCKET_CAPACITY, REFILL_RATE, now
  );

  if (!allowed) {
    return res.status(429).json({
      error: 'Too many requests. Please slow down.',
      retryAfter: Math.ceil(1000 / REFILL_RATE),
    });
  }

  next();
}

module.exports = rateLimiter;

A few things to note here:

  • Lua atomicity: Redis executes the entire script as a single transaction. No two requests can read stale token counts.
  • Time-based refill: Instead of a scheduled job, the refill is computed lazily at request time from the elapsed milliseconds since last access. This scales to millions of keys without cron overhead.
  • TTL on keys: The EXPIRE call ensures Redis doesn't accumulate stale keys for inactive users.

Wiring It Into Express

// app.js
const express = require('express');
const rateLimiter = require('./rateLimiter');

const app = express();
app.use(express.json());

// Apply globally, or scope to specific routers
app.use('/api/', rateLimiter);

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

app.listen(3000, () => console.log('API running on port 3000'));

Handling Mobile Users Gracefully

A hard 429 with no guidance frustrates users and causes more retries — the opposite of what you want. Two production practices help:

Return Retry-After Headers

Add Retry-After and X-RateLimit-* headers to every response, not just rejections. Clients that respect standard HTTP headers can back off intelligently instead of hammering the server.

res.set('X-RateLimit-Limit', BUCKET_CAPACITY);
res.set('Retry-After', Math.ceil(1000 / REFILL_RATE));

Differentiate by User, Not Just IP

Shared mobile NAT (common across African telco networks) means dozens of legitimate users may share a single IP. Where possible, rate limit by authenticated user ID once a token is validated, falling back to IP only for unauthenticated endpoints.

const identifier = req.user?.id || req.ip;

Tiered Limits for Different Consumers

Not all clients are equal. A mobile app consumer hitting /api/feed deserves different headroom than a third-party developer calling your bulk export endpoint. Implement tiered buckets by passing the tier into your middleware:

  • Free tier: 20 tokens, refill 5/sec
  • Pro tier: 100 tokens, refill 20/sec
  • Internal services: No limit (whitelist by IP or service token)

Store the tier alongside the JWT claims and look it up at the middleware layer. This single architectural decision can double the revenue leverage of your API product.


Why This Matters for Your Project

If you are building a SaaS product, a mobile backend, or a public-facing API in Ghana or anywhere on the continent, rate limiting is not optional polish — it is core infrastructure. An unprotected API can be taken offline by a single badly-written client script. A well-implemented token-bucket limiter with Redis keeps your service available, protects your database from query floods, and gives you the foundation to monetise API access through usage tiers. The overhead is minimal: Redis round-trips add roughly 1–3ms per request. The protection it buys is worth orders of magnitude more than that cost.