- Next.js
- MongoDB
- Vercel
- Security
Rate Limiting Next.js API Routes on Vercel Without Redis
In-memory rate limiters don't work on serverless. Here's a 30-line rate limiter built on MongoDB's atomic upserts and TTL indexes, the trade-offs, and when to switch to Redis.
· 5 min read
On this page
My portfolio site has a public AI chatbot backed by the Gemini API, a login form and a contact form. All three are classic abuse targets: a script can burn through an API bill, brute-force passwords or flood an inbox in minutes. They needed rate limiting.
The usual advice is "add Redis." But the project already ran on MongoDB, it's deployed on Vercel, and I didn't want another paid service for three endpoints. Here's the limiter I built instead, and why the obvious approach doesn't work.
Why an in-memory limiter fails on serverless#
The first thing most tutorials show is something like this:
const hits = new Map<string, number>();
export function isAllowed(ip: string) {
const count = (hits.get(ip) ?? 0) + 1;
hits.set(ip, count);
return count <= 30;
}On a single long-running Node.js server, that's (roughly) fine. On Vercel it's close to useless:
- Each function instance has its own memory. Under load, Vercel runs many instances in parallel, and each one has its own
Map. An attacker's requests get spread across them, so the real limit is "30 × however many instances are warm." - Instances are recycled. A cold start wipes the counts.
The counter has to live somewhere every instance can see, which means a shared store. MongoDB already was one.
The design: fixed-window counters#
The simplest algorithm that works is a fixed window: split time into buckets (say, one hour), and count requests per key per bucket.
- Key: something like
chat:203.0.113.7orlogin-email:jane@example.com - Window number:
Math.floor(now / windowSeconds) - Document
_id:${key}:${window}
Every request increments that document's counter. If the count is over the limit, reject. When the window ends, the next request lands in a fresh document with a fresh count.
Two MongoDB features make this clean:
findOneAndUpdatewithupsertand$incis atomic. Two simultaneous requests can't both read "29" and both write "30"; MongoDB serialises the increment.- TTL indexes delete expired documents automatically, so the collection never grows without bound.
The code#
import mongoose, { Schema } from "mongoose";
import { connectDB } from "@/lib/db";
const RateLimitSchema = new Schema({
_id: String,
count: Number,
// TTL index: MongoDB deletes the doc once expiresAt has passed
expiresAt: { type: Date, expires: 0 },
});
const RateLimit =
mongoose.models.RateLimit ?? mongoose.model("RateLimit", RateLimitSchema);
/** true = allowed, false = over the limit for this window */
export async function rateLimit(key: string, limit: number, windowSec: number) {
await connectDB();
const window = Math.floor(Date.now() / 1000 / windowSec);
const doc = await RateLimit.findOneAndUpdate(
{ _id: `${key}:${window}` },
{
$inc: { count: 1 },
$setOnInsert: { expiresAt: new Date((window + 1) * windowSec * 1000) },
},
{ upsert: true, returnDocument: "after" },
).lean<{ count: number }>();
return (doc?.count ?? 0) <= limit;
}A few things worth pointing out:
$setOnInsertsets the expiry only when the document is created, so later increments don't keep pushing it back.expires: 0on the field tells Mongoose to create a TTL index that expires documents at theexpiresAttime. MongoDB's TTL monitor runs about once a minute, so documents can outlive their window slightly. That's harmless, because the next window uses a different_idanyway.returnDocument: "after"returns the incremented count in the same round trip, so there's one database operation per request.
Using it in a route#
import { rateLimit } from "@/lib/rate-limit";
export async function POST(req: Request) {
const ip = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
if (!(await rateLimit(`chat:${ip}`, 30, 60 * 60))) {
return new Response("Too many messages. Please try again later.", { status: 429 });
}
// ... call the model
}Return HTTP 429 and a human-readable message. My chatbot UI checks for 429 and shows the message as the bot's reply instead of a generic "something went wrong."
Choosing keys: the part that actually matters#
The algorithm is the easy part. Deciding what to count is where security comes from.
Login: limit per IP and per account.
const [ipOk, emailOk] = await Promise.all([
rateLimit(`login-ip:${ip}`, 10, 15 * 60),
rateLimit(`login-email:${email.toLowerCase()}`, 10, 15 * 60),
]);
if (!ipOk || !emailOk) return tooManyAttempts();Per-IP alone doesn't stop an attacker rotating through proxies against one account. Per-account alone lets one IP spray guesses across thousands of accounts. Both together cover both attacks.
Run the limiter before expensive work. In the chat route, the check runs before request validation and long before the Gemini call, so rejected requests cost one cheap MongoDB write and nothing else.
Be careful where the IP comes from. On Vercel, x-forwarded-for is set by the platform, so the first entry is trustworthy. If you self-host behind your own proxy, make sure the proxy overwrites that header. Otherwise clients can send any value they like and pick their own rate-limit bucket. Also watch for the "unknown" fallback: if your setup never sets the header, every visitor shares one bucket and your limit becomes global.
Trade-offs, honestly#
This isn't the right limiter for every app:
- Bursts at window edges. A fixed window allows up to 2× the limit across a boundary (30 requests at 12:59 and 30 more at 13:00). For abuse protection that's fine. If you need smooth limits, use a sliding window or token bucket.
- One database write per request. For a few endpoints that's nothing. For a high-traffic public API, that write load belongs in Redis, not your primary database.
- Latency. A MongoDB round trip is slower than an in-region Redis call. On login and chat endpoints, which already do far slower work, it's noise.
When any of those starts to hurt, the same rateLimit(key, limit, windowSec) signature can be backed by Redis (for example Upstash, available through the Vercel Marketplace) with no changes to the call sites. That's the real benefit of keeping the function this small.
Takeaways#
- Serverless means no shared memory: rate-limit state has to live in a shared store.
- If you already run MongoDB, an atomic
upsert+$incand a TTL index give you a correct limiter with zero new infrastructure. - Spend your thinking on keys (per IP, per account, per user) and on checking before expensive work. That's where the protection actually comes from.