Headline: An in-memory rate limiter is wrong on serverless because every function instance keeps its own counter, so a limit of 10 requests per minute becomes 10 requests per minute per instance. The second trap is that every Next.js Server Action POSTs to the URL of the page that called it, so path-based limiting in middleware cannot tell one action from another.
A rate limiter is a counter with a deadline: allow N requests per identity per time window and reject the rest with HTTP 429. I have written that in ten lines before and been happy with it. Moving the same idea into a Next.js 16 App Router project on Vercel broke it in three places I did not expect — where the counter lives, who counts as an identity, and how you attach a limit to a Server Action that has no URL of its own.
Key takeaways
- An in-memory
Maprate limiter is per-instance on serverless. With ten warm function instances, a 10-requests-per-minute limit permits 100 requests per minute. - Next.js middleware is the cheapest place to shed abusive traffic because it runs before the route's own function boots, but it also sees RSC prefetch requests that carry the
RSC: 1header and that the user never intentionally made. -
NextRequest.ipwas removed in Next.js 15. On Vercel, read the client address withipAddress(request)from@vercel/functionsinstead of trusting a rawx-forwarded-forheader. - Every Next.js Server Action POSTs to the current page URL and carries a build-generated
Next-Actionheader, so the only reliable place to limit a specific action is inside the action body. - Reject with status 429 and a
Retry-Afterheader, and decide fail-open versus fail-closed per route before your Redis has its first outage.
Why does an in-memory rate limiter break on serverless?
A module-scope Map is private to one function instance, and a serverless platform runs many instances at once. Each instance therefore enforces the full limit on its own, so the effective limit is your configured limit multiplied by the number of warm instances. That number is not something you control or can observe from inside the request.
This is the code I have shipped and regretted:
// Do not ship this to a serverless runtime.
const hits = new Map<string, { count: number; reset: number }>();
export function limit(key: string, max = 10, windowMs = 60_000) {
const now = Date.now();
const entry = hits.get(key);
if (!entry || entry.reset < now) {
hits.set(key, { count: 1, reset: now + windowMs });
return { ok: true };
}
entry.count += 1;
return { ok: entry.count <= max };
}
Vercel Fluid Compute makes this harder to notice rather than easier. Fluid Compute reuses a single function instance across concurrent requests instead of spawning one per request, so the Map survives far longer than it did under classic serverless. In local development and in a quiet preview deployment the limiter looks correct. It only comes apart under traffic spread across enough instances to matter, and nothing in the logs announces it.
The Map also never shrinks. On a long-lived instance every unique key you have ever seen stays resident until the instance is recycled, which is a slow memory leak wearing a rate limiter costume.
The fix is not a cleverer Map. The counter has to live in a store that every instance shares and that supports an atomic increment: Redis, or any datastore with a compare-and-set primitive.
Should the limit run in middleware or in the route handler?
Both, for different jobs. Middleware runs before Next.js resolves the route, so a request rejected there never boots the route's function and never touches your database. That makes middleware the correct place for a coarse, identity-agnostic abuse limit. The route handler knows the authenticated user, the parsed body, and the business meaning of the call, which makes it the correct place for a per-user quota.
// middleware.ts
import { NextResponse, type NextRequest } from 'next/server';
import { ipAddress } from '@vercel/functions';
import { ratelimit } from '@/lib/ratelimit';
export const config = {
matcher: ['/api/:path*', '/login', '/signup'],
};
export async function middleware(request: NextRequest) {
// RSC prefetches fire on link hover. The user did not ask for these.
if (request.headers.get('rsc') === '1') return NextResponse.next();
const ip = ipAddress(request) ?? '127.0.0.1';
const { success, limit, remaining, reset } = await ratelimit.limit(`ip:${ip}`);
if (success) return NextResponse.next();
return NextResponse.json(
{ error: 'rate_limited', message: 'Too many requests.' },
{
status: 429,
headers: {
'Retry-After': String(Math.ceil((reset - Date.now()) / 1000)),
'X-RateLimit-Limit': String(limit),
'X-RateLimit-Remaining': String(remaining),
'X-RateLimit-Reset': String(reset),
},
},
);
}
The rsc header check is the line I added after watching my own quota drain while I did nothing but move a mouse. The Next.js router prefetches linked routes on hover and on viewport entry, and those requests are real HTTP requests that hit middleware with an RSC: 1 header. Counting them means a user who scrolls a navigation-heavy page is rate limited before they click anything.
Since Next.js 15.5 you can also opt middleware into the Node.js runtime with export const config = { runtime: 'nodejs' }, which lets you use a normal Redis client there. I still prefer an HTTP-based store in middleware, because middleware sits on the latency path of every matched request.
| Placement | Sees | Cost of a rejection | Use it for |
|---|---|---|---|
| Middleware | URL, headers, cookies, IP | Lowest — route function never boots | IP-level abuse and brute-force shielding |
| Route handler | Everything, including session and body | Function has already started | Per-user quotas, per-endpoint cost control |
| Server Action body | Session, typed arguments | Function has already started | Form submissions and mutations |
Which algorithm should I actually use?
Pick the cheapest algorithm whose failure mode you can live with.
-
Fixed window keeps one counter per window, so a check is a single
INCR. Its flaw is the boundary: ten requests at 11:59:59 and ten more at 12:00:00 pass a "ten per minute" limit while delivering twenty requests in one second. - Sliding window log stores a timestamp per request and is exact, but its memory grows with your traffic, which is the wrong direction for a defence against traffic.
- Sliding window counter weights the previous window by how much of it still overlaps the current one. Bounded memory, no boundary burst, and my default.
- Token bucket gives each identity a capacity and a refill rate, so a quiet client may spend saved tokens at once. Right for APIs whose clients legitimately batch.
The @upstash/ratelimit package ships all four as fixedWindow, slidingWindow, tokenBucket, and cachedFixedWindow. If you write your own against Redis, the thing to get right is atomicity. INCR followed by a separate EXPIRE is two round trips, and if the process dies between them you have created a key with no expiry, which locks that identity out permanently. Do it in one script:
-- One round trip. The TTL is set only on the first hit of a window.
local current = redis.call('INCR', KEYS[1])
if current == 1 then
redis.call('PEXPIRE', KEYS[1], ARGV[1])
end
return current
How do I identify the client when everything sits behind a proxy?
A rate limit is only as good as its key. NextRequest.ip and NextRequest.geo were removed in Next.js 15, so reading request.ip is now a type error rather than a subtle wrong answer. On Vercel the replacement is ipAddress(request) from @vercel/functions.
Do not key on a raw x-forwarded-for header unless you are certain a trusted proxy overwrites it. x-forwarded-for is an ordinary request header, so on any origin reachable directly, an attacker sets it to a new value per request and gets an unlimited number of fresh limit buckets. That is a complete bypass, not a partial failure.
IPv6 needs its own rule. A single residential subscriber is routinely assigned an entire /64 prefix, so keying on the full 128-bit address hands one attacker 2^64 distinct identities.
function identityKey(request: NextRequest, userId?: string) {
// A stable account beats a network address whenever you have one.
if (userId) return `user:${userId}`;
const ip = ipAddress(request) ?? '0.0.0.0';
if (ip.includes(':')) {
// One subscriber can own a whole /64. Key the prefix, not the address.
return `ip6:${ip.split(':').slice(0, 4).join(':')}`;
}
return `ip4:${ip}`;
}
How do I rate limit a Server Action when every action is a POST to the same URL?
A Server Action is a function marked with the 'use server' directive that the client invokes over the network. The invocation is an HTTP POST to the URL of the page the action was called from, with a Next-Action header containing a build-generated identifier for that specific action. There is no dedicated route path, which is exactly what breaks the obvious approach.
In middleware, a POST from a contact form on /contact and a POST from a delete-account button on /contact are the same URL and the same method. You can read request.headers.get('next-action') to at least distinguish action POSTs from ordinary document requests, but that identifier is a hash that changes when the build changes, so branch on its presence and never on its value.
The reliable place is inside the action:
'use server';
import { headers } from 'next/headers';
import { auth } from '@/lib/auth';
import { ratelimit } from '@/lib/ratelimit';
export async function sendMessage(_prev: State, formData: FormData): Promise<State> {
const session = await auth();
const key = session?.user.id ?? (await headers()).get('x-forwarded-for') ?? 'anon';
const { success, reset } = await ratelimit.limit(`action:sendMessage:${key}`);
if (!success) {
const seconds = Math.ceil((reset - Date.now()) / 1000);
// Return a value. A thrown error reaches production as an opaque digest.
return { ok: false, error: `Too many messages. Try again in ${seconds}s.` };
}
// ...the real work
return { ok: true };
}
Returning a value rather than throwing matters. An uncaught error inside a Server Action is redacted in production and surfaces to the client as a generic message with a digest, so the user is told something went wrong instead of being told to wait forty seconds. Rate limiting is a normal outcome, not an exception.
What should a 429 response actually contain?
HTTP 429 Too Many Requests is the correct status, and Retry-After is the header that makes a limiter usable by anyone other than a human staring at a browser. Its value is either a number of seconds or an HTTP date.
Alongside it, emit the limit state. The X-RateLimit-Limit / X-RateLimit-Remaining / X-RateLimit-Reset convention is not a standard but it is what most SDKs already parse. The IETF draft draft-ietf-httpapi-ratelimit-headers defines RateLimit and RateLimit-Policy as structured fields; adopt it only if your consumers understand it.
Two mistakes I have made and seen: returning 403 for a rate limit, which tells the client to stop forever rather than to retry, and returning 500, which pollutes your error rate with your own defences working correctly.
Then decide what happens when the store is unreachable, because it will be. Failing open on a login endpoint turns a Redis outage into an open brute-force window. Failing closed on a public read endpoint turns a Redis outage into a full outage of your site. I choose per route: fail closed on authentication and on anything that spends money, fail open on reads.
FAQ
Q: Can I rate limit in Next.js without Redis?
A: Only if your app runs as a single long-lived process, such as one container instance. On any serverless or autoscaled deployment the counter must live in a store shared across instances, because in-process state is multiplied by your instance count.
Q: Does Next.js middleware run on RSC prefetch requests?
A: Yes. Router prefetches are real HTTP requests that match your middleware matcher and carry the RSC: 1 header. Exclude them from user-facing quotas or they will consume a visitor's budget before the visitor clicks anything.
Q: How do I get the client IP in Next.js 15 and Next.js 16?
A: NextRequest.ip was removed in Next.js 15. On Vercel, call ipAddress(request) from @vercel/functions. Elsewhere, read the forwarded header your own trusted proxy sets and confirm that the proxy overwrites rather than appends it.
Q: Should a rate limiter fail open or fail closed when Redis is down?
A: Decide per route. Fail closed on login, signup, password reset, and payment endpoints, where failing open creates a security window. Fail open on public reads, where failing closed converts a dependency outage into a site outage.
Q: Is Vercel BotID a replacement for rate limiting?
A: No. Vercel BotID is bot detection, which answers whether a caller is automated. A rate limit answers how often any caller, human or not, may perform an expensive operation. They defend different things and compose well together.
Originally published on devya.dev. Also on eng-ahmed.com. Built by Devya Solutions.
Top comments (1)
the RSC prefetch hitting middleware with the RSC:1 header is the kind of thing you only find by watching your own quota drain, good catch. one thing though, your sendMessage example falls back to headers().get('x-forwarded-for') directly for the anon key, but that's the exact header you warned against trusting raw a few sections earlier since it's attacker-settable without a trusted proxy overwriting it. seems like the action-level fallback should go through the same ipAddress() helper as the middleware example, or is there a reason to treat it differently once a request is past the middleware check