Rate Limiting Next.js Routes When Redis Might Be Missing
One limiter for chat and view counts — Upstash when it is configured, an in-memory bucket when it is not, and why that fallback is only a local guard.
Nguyễn Đình Giang
· · 2 min read
Frontend developer in Ha Noi. I write about the Next.js, WordPress, and performance work I actually ship.
Public API routes on a portfolio get poked. Contact, chat, and view counts do not need a perfect global limiter on day one. They do need a ceiling so one client cannot spin the function or write Redis forever.
I use one helper. It prefers Upstash. If Redis is unset or the call throws, it counts in process memory.
The Redis path
const count = await redis.incr(key)
if (count === 1) await redis.pexpire(key, windowMs)
return count > limitINCR creates the key at 1. The first hit sets the TTL. Later hits in that window only increment, so the window does not slide forward on every request. Over the limit returns true and the route responds 429.
Keys are specific: chat:rate:${ip} at 20 per minute, views:rate:${ip} at 60 per minute. A chatty visitor should not lock themselves out of a page view, and a view burst should not mute the chatbot.
The memory path
No UPSTASH_REDIS_REST_URL (or KV_REST_API_*) means a Map of { count, resetAt } on that instance. A Redis error logs and falls through to the same map. The route still limits. It does not fail open to unlimited.
That map is not a product limiter:
- Each server instance has its own buckets
- A restart or a new deploy wipes them
- Two instances can each allow the full limit
For local dev and a single container that is fine. For a multi-instance host, set Upstash or the limit is a courtesy, not a guarantee.
Which IP
const forwarded = req.headers.get("x-forwarded-for")
if (forwarded) return forwarded.split(",")[0]?.trim() || "unknown"
return req.headers.get("x-real-ip") || "unknown"The first hop in x-forwarded-for is the client the proxy claims to have seen. Missing both headers becomes the shared key "unknown", so those requests share one bucket instead of each looking unique.
Trust that header only behind a proxy you control. If the app is reachable directly, a client can send any x-forwarded-for and dodge the bucket.
What this does not do
It does not prove the visitor is human. It does not replace auth on a route that changes data you care about. View counts still only increment for slugs that exist, and chat still validates the body with Zod before any model call. The limiter is the first cheap check, not the only one.
Related on this site
- Counting blog views once — where the 60/min views limit shows up
- Chatbot without Gemini — the 20/min chat limit
- Visitor stats without a disk — 30/min on
/api/visitors - Rebuilding this portfolio — context for these routes
Need this kind of feature on your site? See hire details →
Get new posts by email
One email when I publish something new. No spam, unsubscribe anytime.

