class TokenBucket {
constructor(capacity, refillPerSecond) {
this.capacity = capacity;
this.refillPerMs = refillPerSecond / 1000;
this.tokens = capacity;
this.lastRefill = Date.now();
}
refill(now) {
const elapsed = now - this.lastRefill;
if (elapsed <= 0) return;
this.tokens = Math.min(this.capacity, this.tokens + elapsed * this.refillPerMs);
this.lastRefill = now;
}
tryRemove(now = Date.now()) {
this.refill(now);
if (this.tokens >= 1) {
this.tokens -= 1;
return { allowed: true, remaining: Math.floor(this.tokens), retryAfterMs: 0 };
}
const deficit = 1 - this.tokens;
const retryAfterMs = Math.ceil(deficit / this.refillPerMs);
return { allowed: false, remaining: 0, retryAfterMs };
}
isFull(now = Date.now()) {
this.refill(now);
return this.tokens >= this.capacity;
}
}
module.exports = TokenBucket;
const TokenBucket = require('./TokenBucket');
class RateLimiterStore {
constructor({ capacity, refillPerSecond, sweepIntervalMs = 60000 }) {
this.capacity = capacity;
this.refillPerSecond = refillPerSecond;
this.buckets = new Map();
this.sweepTimer = setInterval(() => this.sweep(), sweepIntervalMs);
if (typeof this.sweepTimer.unref === 'function') this.sweepTimer.unref();
}
getBucket(key) {
let bucket = this.buckets.get(key);
if (!bucket) {
bucket = new TokenBucket(this.capacity, this.refillPerSecond);
this.buckets.set(key, bucket);
}
return bucket;
}
tryRemove(key) {
return this.getBucket(key).tryRemove();
}
sweep() {
const now = Date.now();
for (const [key, bucket] of this.buckets) {
if (bucket.isFull(now)) this.buckets.delete(key);
}
}
stop() {
clearInterval(this.sweepTimer);
}
}
module.exports = RateLimiterStore;
const RateLimiterStore = require('./RateLimiterStore');
function rateLimiter(options = {}) {
const {
capacity = 20,
refillPerSecond = 5,
keyGenerator = (req) => req.ip,
message = 'Too many requests, please slow down.'
} = options;
const store = new RateLimiterStore({ capacity, refillPerSecond });
const middleware = (req, res, next) => {
const key = keyGenerator(req);
const result = store.tryRemove(key);
res.setHeader('X-RateLimit-Limit', capacity);
res.setHeader('X-RateLimit-Remaining', result.remaining);
if (result.allowed) return next();
const retryAfterSec = Math.ceil(result.retryAfterMs / 1000);
res.setHeader('Retry-After', retryAfterSec);
return res.status(429).json({ error: message, retryAfter: retryAfterSec });
};
middleware.store = store;
return middleware;
}
module.exports = rateLimiter;
const express = require('express');
const rateLimiter = require('./rateLimiter');
const app = express();
app.set('trust proxy', 1); // required for accurate req.ip behind a proxy
const apiLimiter = rateLimiter({
capacity: 30,
refillPerSecond: 10,
keyGenerator: (req) => req.get('x-api-key') || req.ip
});
app.use('/api', apiLimiter);
app.get('/api/search', (req, res) => {
res.json({ query: req.query.q || '', results: [] });
});
const server = app.listen(3000, () => {
console.log('listening on :3000');
});
process.on('SIGTERM', () => {
apiLimiter.store.stop();
server.close();
});
This snippet implements a rate limiter for an Express API using the classic token bucket algorithm, kept entirely in memory. The token bucket models a fixed-size reservoir that refills at a steady rate: each request consumes a token, and when the bucket is empty the request is rejected. Compared to a naive fixed-window counter, the token bucket smooths traffic and permits short bursts (up to the bucket capacity) while still enforcing a long-run average rate — which is why it is the default choice for most public APIs.
In TokenBucket, refills are computed lazily rather than on a timer. The refill method looks at the elapsed time since lastRefill and adds elapsed * refillPerMs tokens, clamped to capacity. This lazy approach means the class holds no setInterval, so an idle bucket costs nothing and the math stays exact regardless of how sporadically it is touched. tryRemove refills first, then removes one token if available, returning a small result object that also exposes retryAfterMs so callers can tell a client precisely how long to wait.
The RateLimiterStore maps an arbitrary key (typically an IP or API key) to its own TokenBucket, creating one on first use with getBucket. Because in-memory buckets would otherwise leak for every unique key ever seen, a periodic sweep evicts buckets that are full and untouched — a full bucket carries no debt, so discarding it is safe and it simply gets recreated if the key returns. The sweep timer is unref'd so it never keeps the process alive on its own.
In rateLimiter middleware, the factory returns standard Express middleware. It derives the key via a configurable keyGenerator, calls tryRemove, and on success sets the informational X-RateLimit-* headers. On failure it sets Retry-After (in whole seconds, rounded up) and responds 429. Two pitfalls worth noting: this store is per-process, so behind multiple instances the effective limit multiplies and a shared store like Redis is needed for a global limit; and req.ip is only trustworthy when trust proxy is configured correctly. For a single service or a first line of defense, this in-memory approach is fast, dependency-free, and easy to reason about.
Share this code
Here's the card — post it anywhere.