Build a rate limiter in Node.js with the token bucket algorithm
Rate limiting protects your API from abuse. Build a small, tested token bucket limiter and see how bursts and refill work.

A rate limiter decides how many requests a client may make in a given time. It protects login forms from password guessing, APIs from abuse and your servers from accidental overload. This project builds one with the token bucket algorithm in plain Node.js, with no packages. The code and outputs below were run with Node.js 22 before publishing.
The token bucket idea
Imagine each client owns a bucket that holds at most 5 tokens.
- Every request must take one token. No token, no service.
- Tokens drip back into the bucket at a steady rate, for example 1 per second.
- The bucket never holds more than its capacity.
The result: a client can make a burst of 5 quick requests, but over a long time it averages 1 request per second. This matches real use better than a strict "1 per second" rule, because people click several times quickly and then pause.
The limiter (limiter.mjs)
// Token bucket: each client has a bucket that holds up to `capacity` tokens
// and refills at `refillPerSecond`. A request costs one token.
export function createLimiter({ capacity = 5, refillPerSecond = 1, now = () => Date.now() } = {}) {
const buckets = new Map();
function take(key) {
const t = now();
const bucket = buckets.get(key) ?? { tokens: capacity, updated: t };
const elapsed = (t - bucket.updated) / 1000;
bucket.tokens = Math.min(capacity, bucket.tokens + elapsed * refillPerSecond);
bucket.updated = t;
const allowed = bucket.tokens >= 1;
if (allowed) bucket.tokens -= 1;
buckets.set(key, bucket);
const retryAfter = allowed ? 0 : Math.ceil((1 - bucket.tokens) / refillPerSecond);
return { allowed, remaining: Math.floor(bucket.tokens), retryAfter };
}
// forget idle clients so the Map cannot grow forever
function cleanup(maxIdleMs = 10 * 60 * 1000) {
const t = now();
for (const [key, bucket] of buckets) if (t - bucket.updated > maxIdleMs) buckets.delete(key);
}
return { take, cleanup, size: () => buckets.size };
}
Key points:
- No timer. We do not run a refill loop. We calculate the refill lazily when a request arrives: elapsed time times the rate. This is cheap, and it works for millions of idle clients.
nowis injectable. The clock is a parameter, so tests can move time forward instantly instead of waiting.retryAftertells a blocked client how many seconds to wait, which we send in theRetry-Afterheader.cleanup()deletes clients that have been idle, so theMapcannot grow forever. Without it, an attacker using many addresses could slowly use up your memory.
Using it in an HTTP server (server.mjs)
import { createServer } from "node:http";
import { createLimiter } from "./limiter.mjs";
const limiter = createLimiter({ capacity: 5, refillPerSecond: 1 });
setInterval(() => limiter.cleanup(), 60_000).unref();
export const server = createServer((req, res) => {
// Behind a proxy, use the address your proxy sets. Never trust X-Forwarded-For from the open internet.
const key = req.socket.remoteAddress;
const result = limiter.take(key);
res.setHeader("X-RateLimit-Remaining", result.remaining);
if (!result.allowed) {
res.writeHead(429, { "Retry-After": result.retryAfter, "Content-Type": "application/json" });
return res.end(JSON.stringify({ error: "Too many requests", retryAfter: result.retryAfter }));
}
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true }));
});
if (process.argv[1] === new URL(import.meta.url).pathname) {
const port = process.env.PORT ?? 3000;
server.listen(port, () => console.log(`Listening on http://localhost:${port}`));
}
Run it
$ PORT=3921 node server.mjs
Listening on http://localhost:3921
$ for i in 1 2 3 4 5 6 7; do curl -si localhost:3921 | grep -E "^HTTP|RateLimit|Retry"; done
HTTP/1.1 200 OK X-RateLimit-Remaining: 4
HTTP/1.1 200 OK X-RateLimit-Remaining: 3
HTTP/1.1 200 OK X-RateLimit-Remaining: 2
HTTP/1.1 200 OK X-RateLimit-Remaining: 1
HTTP/1.1 200 OK X-RateLimit-Remaining: 0
HTTP/1.1 429 Too Many Requests X-RateLimit-Remaining: 0 Retry-After: 1
HTTP/1.1 429 Too Many Requests X-RateLimit-Remaining: 0 Retry-After: 1
(wait 2 seconds)
$ curl -s -o /dev/null -w "%{http_code}\n" localhost:3921
200
The first five requests pass (a burst), the next two get 429 Too Many Requests with a Retry-After header, and after waiting a couple of seconds the bucket has refilled enough for another request.
Tests (limiter.test.mjs)
import test from "node:test";
import assert from "node:assert/strict";
import { createLimiter } from "./limiter.mjs";
function setup(options) {
let time = 0;
const limiter = createLimiter({ ...options, now: () => time });
return { limiter, advance: (ms) => (time += ms) };
}
test("allows a burst up to capacity, then blocks", () => {
const { limiter } = setup({ capacity: 3, refillPerSecond: 1 });
assert.deepEqual([1, 2, 3].map(() => limiter.take("a").allowed), [true, true, true]);
const blocked = limiter.take("a");
assert.equal(blocked.allowed, false);
assert.equal(blocked.retryAfter, 1);
});
test("refills over time", () => {
const { limiter, advance } = setup({ capacity: 2, refillPerSecond: 1 });
limiter.take("a"); limiter.take("a");
assert.equal(limiter.take("a").allowed, false);
advance(1000);
assert.equal(limiter.take("a").allowed, true);
});
test("never refills beyond capacity", () => {
const { limiter, advance } = setup({ capacity: 2, refillPerSecond: 1 });
advance(60_000);
assert.equal(limiter.take("a").remaining, 1);
});
test("clients are independent", () => {
const { limiter } = setup({ capacity: 1, refillPerSecond: 1 });
assert.equal(limiter.take("a").allowed, true);
assert.equal(limiter.take("a").allowed, false);
assert.equal(limiter.take("b").allowed, true);
});
test("cleanup removes idle clients", () => {
const { limiter, advance } = setup({});
limiter.take("a");
advance(11 * 60 * 1000);
limiter.cleanup();
assert.equal(limiter.size(), 0);
});
$ node --test
# tests 5
# pass 5
# fail 0
Because time is injected, "advance 60 seconds" takes no real time, and we can also prove the bucket never refills past capacity.
HTTP details done properly
- Status
429 Too Many Requestsis the standard response. Retry-Afterhelps well-behaved clients back off instead of hammering you.- Remaining-count headers such as
X-RateLimit-Remainingare a common convention, not an official standard, so check what your API consumers expect. - Do not reveal more than needed on login endpoints, and keep error messages the same for existing and missing accounts.
Choosing the limit key
The example uses the client IP address. That has traps:
- Behind a proxy or CDN,
remoteAddressis the proxy, so everyone shares one bucket. Use the client IP that your trusted proxy sets, and never trustX-Forwarded-Forstraight from the internet, because clients can forge it. - Many users can share one IP (offices, mobile networks). Limiting by IP alone can block innocent people. For logged-in users, limit by user id or API key instead.
- For login protection, combine per-IP and per-account limits, since attackers can spread guesses across many IPs.
Limits of this version (important)
- It lives in one process's memory. Run two servers behind a load balancer and each has its own buckets, so the real limit doubles. It also resets on restart. For several servers, store counters in a shared store such as Redis, with atomic operations (a Lua script or
INCRwith expiry) so two requests cannot both take the last token. - It is a basic defence, not complete protection. Large attacks need help from your CDN or a web application firewall, and brute-force defence also needs strong passwords, lockout or delays, and monitoring.
- Choose numbers from real traffic. Too strict blocks real users; too loose does nothing. Start by measuring.
Other algorithms (for comparison)
| Algorithm | Behaviour | Note |
|---|---|---|
| Fixed window | N requests per clock minute | Simple, but allows 2N in a burst across a boundary |
| Sliding window | N requests in any rolling minute | Smoother, uses more memory |
| Token bucket | steady rate with bursts | Our choice: simple and burst-friendly |
| Leaky bucket | smooths output to a constant rate | Good for protecting a backend with fixed capacity |
Challenges
- Different limits per route (stricter on
/login). - Limit by API key when the
Authorizationheader is present. - Return the
RateLimitheaders in the format your API documents. - Move the buckets to Redis so several servers share them.
- Add a "penalty": block for 15 minutes after 10 blocked requests.
Takeaway: a token bucket allows short bursts and a steady average, needs no timer, and is easy to test when time is injectable. Choose the key carefully, answer with 429 and Retry-After, and remember that a limiter in one process is only the first layer.
Build-along projects series, Part 2 (tools you can reuse): 3 of 5. Previous: Clean messy CSV data with Python: names, dates, phones and duplicates.


