Rate limiting is the difference between an endpoint and an open invitation. Any route that sends email, costs money, hits a third party, or guesses a credential is a target: a signup form is a spam relay, a login is a brute-force surface, a contact form is a way to mail-bomb your inbox. The fix is simple in principle (count requests per identity per window, refuse past a threshold) and has exactly one trap that catches almost everyone: where you keep the count. On serverless, the obvious in-memory counter silently does nothing.
This page is the rate-limiting setup that holds up in production: a shared Redis-backed limiter as the real backend, an in-memory fallback for local dev and tests, and sensible keys so you throttle the attacker without locking out everyone behind the same office IP.
Why the obvious approach fails on serverless
The first thing everyone writes is a module-level Map that counts requests. It works perfectly on your laptop and fails the moment you deploy to a serverless platform. Each cold start is a fresh process with its own empty Map, so an attacker just keeps retrying until they land on a new instance with a clean counter. The count has to live in a store all instances share. That means Redis (or similar), not process memory.
// WRONG on serverless: every cold-start lambda gets its own empty Map,
// so the limit resets per instance and an attacker just retries.
const counts = new Map<string, number>();The rules
Whenyou add rate limiting to a multi-instance or serverless deploy
Douse a shared store (Upstash Redis or equivalent), never a per-process Map. The counter must be visible to every instance or it does not limit anything.
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
const limiter = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(5, "60 s"),
prefix: "app:rl", // isolate keys if the DB is shared
});WhenRedis env vars are missing (local dev, tests, a fresh clone)
Dofall back to an in-memory limiter so dev still works, but make it explicit that it is single-process only and never the production backend.
function rateLimit(key: string, limit: number, windowMs: number) {
const redis = getUpstashLimiter(limit, windowMs);
return redis ? redis.limit(key) : memRateLimit(key, limit, windowMs);
}Whenyou choose what to key the limit on
Dokey by the most specific stable identity available, and often combine two: per-IP AND per-account/email. IP alone punishes shared networks; identity alone is bypassed by new accounts.
const a = await rateLimit(`login:ip:${ip}`, 10, 60_000);
const b = await rateLimit(`login:email:${hashEmail(email)}`, 5, 60_000);
if (!a.ok || !b.ok) return tooMany();Whenthe rate-limit key includes an email or other PII
Dohash it before using it as a key (and before logging). The limiter store should never hold raw personal data.
import { createHash } from "crypto";
const hashEmail = (e: string) =>
createHash("sha256").update(e.trim().toLowerCase()).digest("hex");Whenyou read the client IP to key a limit
Doread it from the forwarded headers your platform sets, with a fallback, since the socket address behind a proxy is the proxy, not the user.
const ip =
h.get("x-forwarded-for")?.split(",")[0]?.trim() ??
h.get("x-real-ip") ?? "0.0.0.0";Whena request exceeds the limit
Doreturn 429 with a clear retry hint and a friendly message, and log the event. Do not leak whether the underlying resource exists (e.g. don't say 'too many attempts for this account' on login).
if (!result.ok) {
log.warn("rate_limited", { key: "login", ip });
return { ok: false, error: "Too many attempts. Please try again later." };
}