Rate limiting without Redis: how far in-memory state actually gets you
Most rate limiting guides assume Redis from minute one. In-memory state is imperfect, but often enough for a contact form. Where the line actually is.

Search for "rate limiting in Next.js" and every result reaches for Redis in the first paragraph. Redis is the right answer at scale — but if you're protecting a contact form on a portfolio site, provisioning a database to count requests is a lot of setup for the problem you have.
Here's the in-memory version, and an honest account of where it breaks.
The implementation
A Map holding timestamps per IP:
// lib/rateLimit.ts
type Entry = { count: number; resetAt: number };
const store = new Map<string, Entry>();
export function rateLimit(
key: string,
limit = 5,
windowMs = 60_000
): { ok: boolean; remaining: number } {
const now = Date.now();
const entry = store.get(key);
if (!entry || now > entry.resetAt) {
store.set(key, { count: 1, resetAt: now + windowMs });
return { ok: true, remaining: limit - 1 };
}
if (entry.count >= limit) {
return { ok: false, remaining: 0 };
}
entry.count++;
return { ok: true, remaining: limit - entry.count };
}
Used in a route handler:
export async function POST(request: Request) {
const ip = request.headers.get('x-forwarded-for')?.split(',')[0] ?? 'unknown';
const { ok } = rateLimit(`contact:${ip}`, 5, 60_000);
if (!ok) {
return Response.json(
{ error: 'Too many requests' },
{ status: 429, headers: { 'Retry-After': '60' } }
);
}
// ... handle the form
}
Thirty lines, no dependencies, no infrastructure.
The leak nobody mentions
That Map grows forever. Every new IP adds an entry, and nothing removes it. On a long-lived server that's a memory leak.
The fix is cleaning up expired entries occasionally — not on a timer, but piggybacked on regular calls:
let lastCleanup = Date.now();
function cleanup(now: number) {
// At most once a minute, and only walking entries that are already dead.
if (now - lastCleanup < 60_000) return;
lastCleanup = now;
for (const [key, entry] of store) {
if (now > entry.resetAt) store.delete(key);
}
}
Call cleanup(now) at the top of rateLimit. A setInterval would also work on a traditional server, but it keeps the process alive and behaves badly in serverless.
Where it actually breaks
Two limitations, and the second is the one that decides whether this is enough for you.
It doesn't survive restarts. Deploy, and every counter resets. For abuse protection this barely matters — an attacker can't force your deploys.
In serverless, the limit is per instance. This is the real one. Vercel, Lambda and friends run several instances of your function in parallel, each with its own memory. A limit of 5 requests per minute across 4 warm instances is, in the worst case, 20 requests per minute.
So the honest way to describe it: it's not "5 per minute", it's "between 5 and 5×N per minute", where N is however many instances happen to be warm.
When that's fine
For most protection, it is. Consider what you're defending against:
| Threat | Is in-memory enough? |
|---|---|
| Someone spamming your contact form by hand | Yes, easily |
| A basic bot hitting a public endpoint | Yes — 20/min still blocks the flood |
| Brute-forcing a login | No — the multiplier is dangerous here |
| Metered API where you bill per request | No — you need exact counts |
| Distributed attack from many IPs | No, but neither does Redis; you need a WAF |
The pattern: if going over the limit costs you money or security, you need shared state. If it just means a few extra emails, in-memory is fine.
When to migrate
Move to Upstash or Redis when any of these becomes true: the limit protects authentication, you're billing by request, you need counters visible across instances for monitoring, or you're already running Redis for something else — at that point there's no reason not to.
The migration is contained if you keep the same signature:
// The call site doesn't change; only the implementation behind it.
const { ok } = await rateLimit(`contact:${ip}`, 5, 60_000);
Write it returning a promise from day one, even while it's synchronous. That way swapping the backend doesn't ripple through every caller.
Wrapping up
In-memory rate limiting is a deliberately imperfect solution, and knowing exactly how it's imperfect is what makes it a reasonable choice. It stops accidental abuse and casual bots with zero infrastructure. It does not give you a hard guarantee — so don't put it in front of anything where crossing the limit really hurts.
If you're hardening a public form, moving email sending server-side is the other half of the same job.
Get new articles by email
One email when we publish something new. No spam, no promotions, unsubscribe in one click.