February 24, 2026/724 words/4 min read
Upstash Solved My Dumbest Problem
Wasting a weekend failing to fix an already solved problem.
I build and host a small app, a bookmark manager I made for myself and a few friends. Nothing fancy, you just save links. You can tag them and search, but not much else. What it does really well is stay out of your way, so my friends love it.
It ran happily for the better part of a year on Vercel's free tier until one week it started getting hammered by bots. A few hundred requests a minute is not much traffic in absolute terms, but it was enough to trigger a usage warning and push my function invocations toward where things stop being free. The bot had found every public endpoint I'd left exposed and was crawling them on a loop. I spent an entire Saturday building a rate limiter from scratch with a Map in memory before I bothered to reach for an off-the-shelf one.
Oops, that didn't work. Vercel functions are stateless, so the Map resets on every cold start. The rate limiter forgets everyone the moment the function spins down. I knew this going in! I just thought I could work around it with some clever timing. Well, I could not. The bots kept hitting while the Map kept resetting, and I sat there watching my usage dashboard climb as my "rate limiter" functioned as a very fancy welcome mat.
The next idea was to track request counts per IP in my existing PostgreSQL database. Each route started with a check that upserted the IP's request count into a new table using a timestamp window. It worked, technically, but added a database round trip to every request and made everything noticeably slower. A tool I built specifically to be fast and stupid was now slow and "clever", all in the name of keeping bots from making it slow.
I saw someone talking about Upstash on YouTube and felt the very specific annoyance that comes from realizing you've been failing to solve a solved problem. Upstash is serverless Redis with an HTTP API. It took me about fifteen minutes to tear out my Postgres rate limiter and replace it. The rest of my infrastructure stayed the same.
npm install @upstash/redis @upstash/ratelimitAfter I created the Redis database, the Upstash console gave me a REST URL and token. I put both in my .env. The middleware looked like this.
import { Redis } from "@upstash/redis";
import { Ratelimit } from "@upstash/ratelimit";
import { NextRequest, NextResponse } from "next/server";
const redis = Redis.fromEnv();
const ratelimit = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(30, "60 s"),
analytics: true,
});
export default async function proxy(request: NextRequest) {
const ip =
request.ip ?? request.headers.get("x-forwarded-for") ?? "127.0.0.1";
const { success, limit, remaining, reset } = await ratelimit.limit(ip);
if (!success) {
return NextResponse.json(
{ error: "Slow down." },
{
status: 429,
headers: {
"X-RateLimit-Limit": limit.toString(),
"X-RateLimit-Remaining": remaining.toString(),
"X-RateLimit-Reset": reset.toString(),
},
},
);
}
const response = NextResponse.next();
response.headers.set("X-RateLimit-Limit", limit.toString());
response.headers.set("X-RateLimit-Remaining", remaining.toString());
return response;
}
export const config = {
matcher: "/api/:path*",
};The middleware stops each IP after thirty requests in sixty seconds, before any of my API routes fire. I chose slidingWindow because a fixed window allows those annoying bursts right at the reset boundary. With analytics turned on, the Upstash console shows which requests were allowed or blocked, which is useful when tuning the limit.
For my bookmark search endpoint, which hits Meilisearch and is the most expensive route in the app, I added a tighter limit directly in the route handler.
import { Redis } from "@upstash/redis";
import { Ratelimit } from "@upstash/ratelimit";
const redis = Redis.fromEnv();
const searchLimit = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(10, "30 s"),
prefix: "ratelimit:search",
});
export async function GET(request: NextRequest) {
const ip = request.ip ?? "127.0.0.1";
const { success } = await searchLimit.limit(ip);
if (!success) {
return NextResponse.json({ error: "Too many searches." }, { status: 429 });
}
const query = request.nextUrl.searchParams.get("q");
if (!query) {
return NextResponse.json({ error: "Missing query." }, { status: 400 });
}
const results = await searchBookmarks(query);
return NextResponse.json(results);
}The middleware catches broad abuse before it reaches a route. The search route gets a tighter limit because Meilisearch is expensive enough that even a legitimate user refreshing too fast can hammer it. The prefix keeps the search keys separate, so this extra limit doesn't interfere with the first one.
I also moved my session tokens out of a Postgres table. Redis lets them expire naturally with TTL. That got me thinking about other small jobs that don't deserve a database table. A page view counter for this blog would be easy. I won't add one, because it's embarrassing how little traffic I get, but I could. Caching an API response that rarely changes only takes a few lines.
// cache an API response for 5 minutes
const cached = await redis.get(`cache:weather:${city}`);
if (cached) return NextResponse.json(cached);
const fresh = await fetchWeatherData(city);
await redis.set(`cache:weather:${city}`, fresh, { ex: 300 });
return NextResponse.json(fresh);Upstash's HTTP API is what makes it fit my setup. A traditional Redis client needs a persistent TCP connection, which is annoying in serverless because it either disappears between invocations or leaves me managing a pool. Upstash uses REST, so each invocation sends an HTTP request and gets a response without anything needing to survive a cold start. For personal projects running on Vercel, that's the right trade-off.
The free tier covers 10,000 commands per day, way more than my apps will ever need. I've been running it for a few weeks without paying a cent. If I ever have to, I'll do it happily. The bots are gone (or at least blocked) and the app is fast again. The cost was deleting about 80 lines of Postgres rate limiting code that I'm embarrassed I even considered writing.