Most backend engineers reach for express-rate-limit or a similar package the moment throttling comes up. That works — until you need to explain why a client is getting 429s at 3 AM, or tune burst limits for a payment API serving 50,000 mobile users in Lagos during a flash sale. When you build the thing yourself, those conversations become easy.
This tutorial walks through a token-bucket rate limiter built on Node.js and Redis, production patterns included.
Why Token Bucket Over Other Algorithms?
There are four common rate-limiting algorithms: fixed window, sliding window log, sliding window counter, and token bucket. Each makes a different trade-off between memory, accuracy, and burst tolerance.
The token bucket model is the closest to how real traffic behaves on mobile networks. Each client gets a "bucket" that fills with tokens at a fixed rate. Every request consumes one token. If the bucket is empty, the request is rejected. If the client goes quiet for a while, tokens accumulate — up to a configurable maximum — so legitimate bursts (like a user syncing offline data after regaining connectivity) are handled gracefully rather than punished.
For African mobile APIs, where connections are often intermittent and clients batch requests aggressively when they reconnect, this tolerance for short bursts is not a nice-to-have. It is a product requirement.
What You Need
- Node.js 18+
- A Redis instance (local or managed — Redis Cloud has a generous free tier)
- The
ioredisclient package
npm init -y
npm install ioredis
The Core Logic
The algorithm in plain English:
- On each request, fetch the client's current token count and the timestamp of their last request from Redis.
- Calculate how many tokens have refilled since the last request based on elapsed time and the refill rate.
- Cap the new count at the bucket's maximum capacity.
- If at least one token is available, subtract one and allow the request.
- If the count is zero, reject with HTTP 429.
- Persist the updated count and timestamp back to Redis atomically.
Atomicity is the critical word in step 6. Without it, two simultaneous requests from the same client can both read a count of 1, both decide they are allowed, and both write back a count of 0 — meaning you let through twice the traffic you intended. Redis Lua scripts run atomically on the server, solving this cleanly.
// rateLimiter.js
import Redis from "ioredis";
const redis = new Redis(process.env.REDIS_URL || "redis://localhost:6379");
const REFILL_RATE = 10; // tokens added per second
const BUCKET_CAPACITY = 50; // maximum tokens a bucket can hold
const TTL_SECONDS = 60; // expire idle keys after 60 s
const luaScript = `
local key = KEYS[1]
local now = tonumber(ARGV[1])
local capacity = tonumber(ARGV[2])
local refillRate = tonumber(ARGV[3])
local data = redis.call("HMGET", key, "tokens", "lastRefill")
local tokens = tonumber(data[1]) or capacity
local lastRefill = tonumber(data[2]) or now
local elapsed = math.max(0, now - lastRefill)
local refilled = elapsed * refillRate
tokens = math.min(capacity, tokens + refilled)
local allowed = 0
if tokens >= 1 then
tokens = tokens - 1
allowed = 1
end
redis.call("HMSET", key, "tokens", tokens, "lastRefill", now)
redis.call("EXPIRE", key, ${TTL_SECONDS})
return { allowed, math.floor(tokens) }
`;
export async function isAllowed(clientId) {
const now = Date.now() / 1000; // seconds with decimals for sub-second accuracy
const key = `rl:${clientId}`;
const [allowed, remaining] = await redis.eval(
luaScript, 1, key, now, BUCKET_CAPACITY, REFILL_RATE
);
return { allowed: allowed === 1, remaining };
}
Wiring It Into an Express API
// middleware/throttle.js
import { isAllowed } from "../rateLimiter.js";
export async function throttle(req, res, next) {
const clientId = req.headers["x-api-key"] || req.ip;
const { allowed, remaining } = await isAllowed(clientId);
res.set("X-RateLimit-Remaining", remaining);
if (!allowed) {
return res.status(429).json({
error: "Too many requests. Please slow down.",
retryAfter: "1s",
});
}
next();
}
Apply it globally or per-route:
app.use("/api/", throttle);
Tuning Parameters for High-Traffic Scenarios
The numbers in this tutorial — 50 tokens capacity, 10 per second refill — are starting points, not gospel. Here is how to think about tuning them for different endpoint classes:
- Authentication endpoints (
/login,/verify-otp): Low capacity (5–10 tokens), slow refill (1–2 per second). Brute-force attacks on OTP flows are a real threat in mobile-first markets. - Data sync endpoints: Higher capacity (100–200 tokens) to accommodate the offline-reconnect burst pattern described earlier. Refill rate can match your database's comfortable read throughput.
- Payment/transaction endpoints: Moderate capacity, but add a secondary per-account-per-day hard limit using a separate Redis counter and an expiry set to midnight. Token bucket alone does not cap cumulative daily volume.
- Public/unauthenticated endpoints: Apply limits by IP, but be cautious — many users in Ghana, Nigeria, and other markets share IPs through carrier-grade NAT on mobile networks. Tighten these limits gradually while watching your 429 rate in production.
What Production Actually Looks Like
A few additions before you ship this:
Distributed Redis: If you run multiple Node.js instances (and at any meaningful scale you will), they must all point to the same Redis. A managed service like Redis Cloud, Upstash, or an AWS ElastiCache instance handles this. Do not run per-instance in-memory counters — you will see inconsistent limits.
Graceful Redis failure: Wrap your isAllowed call in a try/catch. If Redis is unavailable, you have two valid choices: fail open (allow all traffic, log the incident) or fail closed (return 503). For most APIs, failing open while alerting is the safer user-experience call.
Monitoring: Track the ratio of 429 responses per endpoint per hour. A sudden spike usually means either a legitimate usage pattern you did not anticipate (scale your limits) or a scripted attack (investigate the client ID).
Per-tier limits: SaaS products with tiered plans should pass the client's plan into the limiter and look up capacity/refill-rate from a config map rather than hardcoding constants. Redis hash keys can hold the tier metadata alongside the token count.
Why This Matters for Your Project
Rate limiting is not just a security feature — it is an infrastructure contract between your API and the applications that depend on it. Building it yourself rather than relying on a black-box library gives your team the fluency to reason about edge cases, adapt to your specific traffic patterns, and debug production anomalies with confidence. Whether you are scaling a fintech API in Accra or a logistics platform serving fleets across West Africa, the difference between a rate limiter you understand and one you do not shows up exactly when uptime matters most.





