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 bucket of tokens refilling while requests take tokens away

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.
  • now is injectable. The clock is a parameter, so tests can move time forward instantly instead of waiting.
  • retryAfter tells a blocked client how many seconds to wait, which we send in the Retry-After header.
  • cleanup() deletes clients that have been idle, so the Map cannot 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 Requests is the standard response.
  • Retry-After helps well-behaved clients back off instead of hammering you.
  • Remaining-count headers such as X-RateLimit-Remaining are 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, remoteAddress is the proxy, so everyone shares one bucket. Use the client IP that your trusted proxy sets, and never trust X-Forwarded-For straight 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 INCR with 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

  1. Different limits per route (stricter on /login).
  2. Limit by API key when the Authorization header is present.
  3. Return the RateLimit headers in the format your API documents.
  4. Move the buckets to Redis so several servers share them.
  5. 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.

Keep reading