Skip to main content
← Back to blogs

Rate limiting my recipe app so nobody runs up my bill

Next.jsWeb DevelopmentFull StackCoding

A logged in user could have scripted ten thousand recipe creations overnight and burned through my paid image quota before lunch. Here's the small Upstash setup I added so that nobody can run up my bill, and why a real person only ever needs a couple of recipes a day anyway.

Here's a question I had while looking at my recipe app one evening: what actually stops one of my own logged in users from creating ten thousand recipes overnight? I had auth. I had a database. I had image uploads. But once you were signed in, nothing said no. And every recipe with a photo costs me money on the upload side, so ten thousand of them would be a real bill landing in my inbox.

The short answer is that I added a rate limiter from Upstash, which is a hosted Redis service, and now each user can only create so many recipes per hour. The interesting part is everything in between: why a signed in user is still untrusted in the way that matters here, why I couldn't just count requests in memory, and why the right number turned out to be small. A real person makes maybe one or two recipes a day. Nobody needs to make a thousand.

What was actually unprotected?

My recipe app is a small Next.js app. I use Clerk for sign in, Drizzle and Postgres for storage, and Uploadthing to host the recipe photos. The whole writing surface is four server actions: create a recipe, update one, delete one, and move recipes between folders. A server action is just a function with "use server" at the top that the browser can call directly, like a tiny API endpoint without the boilerplate.

Every one of those actions checks that you're logged in. If there's no Clerk user, it throws Unauthorized and stops. So I figured the doors were locked. And they were, in the sense that you can't see or touch anybody else's recipes. The thing I missed is that locked is not the same as metered. Being logged in answers the question "are you allowed in at all?" It says nothing about "how many times per minute?"

Think of it like a gym membership card. The card proves you're a member and the turnstile lets you in. But a working turnstile doesn't stop you from spinning through it five hundred times in a row. To the server action, a logged in user is a fully trusted caller, and it'll happily run createRecipe as fast as the browser can fire it. That's not some exotic exploit. That's the default behaviour of every server action.

The reason this matters for me specifically is the photo. Each recipe create can push an image to Uploadthing, and Uploadthing bills by storage. So a script firing the create action in a loop isn't just noise in my database, it's gigabytes of images I'm paying to store. The realistic usage is the opposite of that. I add a recipe when I cook something worth keeping, which is once or twice a day on a good day. There's genuinely no honest reason for the same person to create hundreds in an hour. So a limit that allows a handful per hour costs my real users nothing and quietly closes the door on anyone trying to run up my bill.

Why couldn't I just count in memory?

My first instinct was the cheap one: keep a plain JavaScript Map of user id to a list of timestamps, check it at the top of the action, and reject anyone who's gone over. No new service, no account, no token. It even works perfectly when you run it on your own machine.

Then it falls apart in production, and the way it falls apart is sneaky. My app runs on Vercel, where each request can land on a different short lived serverless instance. Picture a row of identical food trucks that open and close all day. Each truck has its own notepad of who ordered what, and there's no shared notepad between them. Truck A counts your first five creates, then your sixth request goes to truck B, which has never heard of you and starts you back at zero. On top of that, any truck can close and reopen empty at any time. So the counter isn't just a bit fuzzy. It's quietly switched off the moment your traffic spreads across instances, which is exactly when you'd want it working.

So the count has to live somewhere outside my app, in one shared place that every instance reads and writes. That's what Redis is good at: a fast little data store that everyone can talk to. I didn't want to run my own Redis server, though, so I used Upstash. You sign up, you get a URL and a token, and you talk to it over plain HTTP. The HTTP part matters more than it sounds, because those short lived serverless functions don't have time to set up a long lived database connection. A quick HTTP call fits their come and go lifecycle exactly.

What does the setup actually look like?

Here's the whole limiter, the real file from the repo. It's short on purpose, and Upstash's library does the hard part.

typescript
import "server-only";

import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";

const redis = new Redis({
  url: process.env.UPSTASH_REDIS_REST_URL!,
  token: process.env.UPSTASH_REDIS_REST_TOKEN!,
});

// 10 uploads per hour per user
export const uploadRatelimit = new Ratelimit({
  redis,
  limiter: Ratelimit.slidingWindow(10, "1 h"),
  prefix: "ratelimit:upload",
});

// 20 deletes per hour per user
export const deleteRatelimit = new Ratelimit({
  redis,
  limiter: Ratelimit.slidingWindow(20, "1 h"),
  prefix: "ratelimit:delete",
});

Reading top to bottom: the first line, server-only, is a small safety pin. It tells the build to fail if this file ever gets imported into browser code, which keeps my secret Upstash token off the client. Then I create a Redis connection from the URL and token. The two exclamation marks just tell TypeScript I'm certain those env vars exist, and if they don't, I'd rather the app crash loudly than run with a limiter that silently does nothing.

The part that does the real work is Ratelimit.slidingWindow(10, "1 h"). That's the rule: ten creates per hour, per user. A sliding window means it always looks at the last sixty minutes from right now, rather than resetting on the clock hour. The reset-on-the-hour version has a silly gap where you could fire ten requests at 12:59 and ten more at 13:00, getting twenty through in two minutes. The sliding window smooths that out so the real rate stays around ten an hour no matter when you start. I get all of that from one function call and zero off-by-one bugs of my own.

I made two limiters because a create and a delete aren't the same risk. A create can spend money on image storage, so ten an hour is the ceiling, and that's still more than any real person needs in a day. A delete just removes a row, which costs me basically nothing, so twenty an hour is fine and I don't want to nag someone who's tidying up their collection. The prefix gives each limiter its own namespace in Redis, so hitting your delete limit doesn't touch your create budget and the other way around.

Putting the limit in place is then one line at the top of the action. Right after I confirm who you are, I ask the limiter, and if you're over the line I throw before anything gets written or uploaded.

typescript
const clerkUser = await currentUser();
if (!clerkUser) throw new Error("Unauthorized");

const { success } = await uploadRatelimit.limit(clerkUser.id);
if (!success) throw new Error("Too many uploads. Please try again later.");

// ...then the real work: insert the recipe row

The order here is deliberate. I check the login first, then the limiter. That way someone who isn't even logged in gets bounced before I ever spend a Redis call on them, so the limiter can't become its own little cost. The key I pass is clerkUser.id, the actual user identity. Not their IP, because friends sharing a wifi would share a bucket and rate limit each other. Not a cookie, because you could clear it and start fresh. Keying on the real account means the limit follows you across phone and laptop, and clearing cookies doesn't reset it. That's exactly what I want, because the limit is protecting my bill, not policing a particular browser.

Things that surprised me

A few things I didn't expect when I wired this up:

The error message the user sees isn't the one I threw. In production, Next.js hides the real text of a thrown error from the browser for security reasons, so the user just gets my generic "please try again" toast, not the literal "Too many uploads". The limiter still did its job, the recipe still wasn't created, but the precise wording lives in my logs, not on the screen.

I didn't put a limiter on everything. The action that moves recipes between folders has none, because it doesn't upload anything or spend money, it just flips a column. My rule ended up being: add a limiter only where the action can multiply my bill, not on every write for the sake of it.

The limiter doubles as a free monitor. Every time it trips, the error flows into Sentry tagged with the user and the action. So if real users start hitting the ceiling, that tells me my limit is too low. And if I never see a single trip, that tells me either nobody's abusing it or my ceiling is so high it's meaningless. Both are useful signals I got for free.

I half expected the extra HTTP call to Redis to feel slow. It doesn't. The create action already does a database insert and a session lookup, so a hundred milliseconds more on a button click nobody is timing just disappears into the noise.

Was it worth it?

For this app, yes, and I'd do it the same way again. The whole thing is a tiny file and one line per action, the Upstash free tier covers a solo project with room to spare, and in exchange I stopped worrying about a logged in user, or a logged in user's bored script, quietly running up a storage bill while I sleep. The numbers are honest too: a person realistically saves one or two recipes a day, so a ceiling of ten an hour never gets in a real cook's way and shuts the door on anyone trying to abuse it.

Here's when I think it's worth caring about: the moment your app has an action that can spend real money on your behalf, like uploading files or sending emails. That's when a rate limiter earns its place, ideally in the same change that adds the paid thing. And here's when it isn't worth it yet: actions that only touch your own cheap database rows, where the worst case is a slightly busier table. For those, skipping the limiter is the right call, and adding one is just process for its own sake. The trick is knowing which kind of action you're looking at, and for me the test is simple. Can this run up my bill? If yes, it gets a limiter before it ships.

Related posts