Rate limiters with node:http and redis
Source: Dev.to

Introduction
When you’re running a plain node:http server, adding rate limiting often feels heavier than it needs to be. Most solutions assume Express‑style middleware or an API gateway sitting in front of your app.
This approach keeps things closer to the metal—no framework assumptions, no magic lifecycle hooks—just functions that decide whether a request is allowed to continue.
Design Goals
- Framework‑agnostic: Works directly with
http.createServer. - Redis‑backed: Safe for multiple processes or servers.
The result is something you can read end‑to‑end without needing to know a specific ecosystem.
The Base Request Handler
Rate limiters follow the same RequestHandler type used elsewhere in the server. A handler inspects the request, possibly writes a response, and either ends the request or lets it continue.
// request-handler.ts
import http from "node:http";
export type RequestHandler = (
incomingMessage: http.IncomingMessage,
serverResponse: http.ServerResponse,
) => ReturnType;
Rate limiter strategies
Fixed‑window counter
The fixed‑window strategy is straightforward:
- Each client gets a Redis key.
- Requests increment a counter.
- The key expires after
windowSizeseconds.
When the limit is reached, further requests are rejected until the window resets.
// redis-rate-limiter.ts
import http from "node:http";
import { createClient, createClientPool } from "redis";
import type { RequestHandler } from "./request-handler";
export function fixedWindowCounter(
client: ReturnType<typeof createClient> | ReturnType<typeof createClientPool>,
config: {
limit: number;
windowSize: number; // seconds
baseKey: string;
getClientIdFromIncomingRequest: (
incomingMessage: http.IncomingMessage,
) => string;
},
): RequestHandler {
return async (incomingMessage, serverResponse) => {
const key = `${config.baseKey}:${config.getClientIdFromIncomingRequest(
incomingMessage,
)}`;
// Current count (0 if the key does not exist or is not a number)
const raw = await client.get(key);
const currentValue = raw ? parseInt(raw, 10) : 0;
const count = Number.isNaN(currentValue) ? 0 : currentValue;
// If the limit has been reached, reject the request
if (count >= config.limit) {
const ttl = await client.ttl(key);
const retryAfter =
ttl > 0
? new Date(Date.now() + ttl * 1000).toUTCString()
: new Date(Date.now() + config.windowSize * 1000).toUTCString();
serverResponse.statusCode = 429;
serverResponse.setHeader("Retry-After", retryAfter);
serverResponse.setHeader("Content-Type", "text/plain");
serverResponse.end("Too Many Requests");
return;
}
// Increment the counter and set the expiration atomically
const transaction = client.multi();
transaction.incr(key);
// NX ensures we only set the expiry the first time the key is created
transaction.expire(key, config.windowSize, "NX");
await transaction.exec();
};
}
When this works well
- Low‑to‑moderate traffic.
- Simple abuse‑prevention requirements.
- Internal or otherwise trusted clients.
Known limitations
- Traffic can spike at window boundaries (the “burst” problem).
- Enforcement is coarse‑grained – the limit is only checked once per window.
If those constraints are acceptable, fixed windows are hard to beat for simplicity.
Sliding‑window counter
Sliding windows trade a little complexity for smoother enforcement. Instead of a single counter, requests are grouped into smaller sub‑windows (or buckets).
Each request:
- Increments the counter for the current sub‑window.
- Sums the counters of all sub‑windows that fall inside the main window.
- Rejects the request if the summed total exceeds the configured limit.
- Allows Redis to expire sub‑window buckets automatically.
// redis-rate-limiter.ts
import http from "node:http";
import { createClient, createClientPool } from "redis";
import * as Utils from "../utils";
import type { RequestHandler } from "./request-handler";
export function slidingWindowCounter(
client: ReturnType<typeof createClient> | ReturnType<typeof createClientPool>,
config: {
limit: number;
windowSize: number; // seconds (overall window)
subWindowSize: number; // seconds (size of each bucket)
baseKey: string;
getClientIdFromIncomingRequest: (
incomingMessage: http.IncomingMessage,
) => string;
},
): RequestHandler {
return async (incomingMessage, serverResponse) => {
const key = `${config.baseKey}:${config.getClientIdFromIncomingRequest(
incomingMessage,
)}`;
// Retrieve all bucket counters for this client
const rawBuckets = await client.hGetAll(key);
const bucketValues = Object.values(rawBuckets)
.map((v) => parseInt(v, 10))
.map((v) => (Number.isNaN(v) ? 0 : v));
// Total requests in the sliding window
const total = Utils.getNumberArraySum(bucketValues);
// If the limit is exceeded, reject the request
if (total >= config.limit) {
const retryAfter = new Date(
Date.now() + config.subWindowSize * 1000,
).toUTCString();
serverResponse.statusCode = 429;
serverResponse.setHeader("Retry-After", retryAfter);
serverResponse.setHeader("Content-Type", "text/plain");
serverResponse.end("Too Many Requests");
return;
}
// Determine the bucket that corresponds to the current time
const now = Date.now();
const bucketTimestamp =
Math.floor(now / (config.subWindowSize * 1000)) *
(config.subWindowSize * 1000);
// Increment the bucket and set its TTL (only the first time it is created)
const transaction = client.multi();
transaction.hIncrBy(key, bucketTimestamp.toString(), 1);
// The bucket should live for the remainder of the main window
transaction.hExpire(
key,
bucketTimestamp.toString(),
config.windowSize,
"NX",
);
await transaction.exec();
};
}
Trade‑offs
| Aspect | Pros | Cons |
|---|---|---|
| Request distribution | Smoother, no hard spikes at window boundaries. | Slightly more Redis memory (one field per sub‑window). |
| Accuracy | Approximates a true sliding window; granularity = subWindowSize. | Large subWindowSize makes the approximation coarse. |
| Complexity | Still relatively simple; uses only native Redis commands. | Requires extra bookkeeping (hash fields, TTL handling). |
| Performance | Reads a single hash (HGETALL) and writes two hash commands atomically. | HGETALL cost grows with the number of active sub‑windows (bounded by windowSize / subWindowSize). |
| Burst handling | Allows limited bursts while respecting the overall rate. | Very aggressive bursts can slip through if they fit within a single sub‑window. |
Tip: Choose
subWindowSizesuch thatwindowSize / subWindowSizestays under ~20–30 buckets for most workloads; this keeps theHGETALLoperation cheap while still giving a fine‑grained sliding window.
Choosing the right strategy
| Scenario | Recommended strategy |
|---|---|
| Simple internal APIs | Fixed‑window counter – minimal code, easy to reason about. |
| Public‑facing endpoints with bursty traffic | Sliding‑window counter – smoother throttling, better user experience. |
| Very high QPS where Redis memory is a concern | Fixed‑window or a token‑bucket implementation (not shown) that stores a single numeric value per client. |
| Need for precise rate limiting (e.g., per‑second limits) | Sliding‑window with a small subWindowSize (e.g., 1 s) or a true token‑bucket algorithm. |
Both implementations above are ready to drop into an existing Node.js service; just pass a Redis client instance and a configuration object that matches the signatures. Happy throttling!
Cons
- More Redis operations.
- Slightly more complexity.
- Still intentionally practical, not theoretical.
Using a Rate Limiter in a Server
Below is a minimal example that wires a rate limiter into a Node HTTP server.
// server.ts
import http from "node:http";
import { createClient } from "redis";
import { fixedWindowCounter } from "./rate-limiters";
const redis = createClient();
await redis.connect();
const rateLimiter = fixedWindowCounter(redis, {
limit: 100,
windowSize: 60,
baseKey: "rate-limit",
// Extract a client identifier from the incoming request
getClientIdFromIncomingRequest: (req) =>
req.socket.remoteAddress ?? "unknown",
});
const server = http.createServer();
server.on("request", async (req, res) => {
await rateLimiter(req, res);
// If the limiter has already ended the response, stop processing
if (res.writableEnded) {
return;
}
res.statusCode = 200;
res.end("OK");
});
server.listen(3000);
If the limiter decides that a request should not proceed, it ends the response, and the rest of the handler is skipped.
Closing Thoughts
This setup isn’t meant to replace dedicated gateways or edge‑rate limiting. It’s a pragmatic, educative solution.
- Everything happens in plain Node.
- Redis does the counting.
- The behavior is easy to reason about by just reading the code.
If that’s the kind of setup you prefer, this approach fits nicely without dragging in a framework.
