Protect Your API: Why You Need Rate Limiting (and How to Add It)

Yesterday, I was talking to a friend about the importance of security when building applications. We were discussing a classic feature: a newsletter subscription form.
You know the drill. You create an endpoint, say /api/subscribe, and you add the usual validations:
- Is the email field empty?
- Does it look like a real email (regex)?
- Is this person already subscribed?
"That's enough, right?" he asked.
"Well," I said, "it's enough to keep your database clean, but it's not enough to keep your server alive."
The problem isn't just bad data—it's volume. Without protection, a simple bot script could hit your endpoint 10,000 times in a minute. Even if you reject the bad emails, your database is still working hard to check if they exist. Your server is still parsing JSON. You are paying for those cycles.
What problem does this solve for us as developers? Rate limiting ensures that no single user (or bot) can degrade the experience for everyone else or bankrupt us with server costs.
The Concept
Think of Rate Limiting as a bouncer at a club. The bouncer doesn't care if your ID is valid (that's the data validation); he cares about how many people are trying to push through the door at once. He keeps the flow manageable.
The "Do It Yourself" Approach (And Why It Fails)
Let's say we want to limit users to 5 requests every 60 seconds.
Your first instinct might be: "I don't need a fancy library. I'll just use a Javascript Map in memory."
It might look something like this in a Next.js API route:
const rateLimitMap = new Map();
export async function POST(request: Request) {
const ip = request.headers.get("x-forwarded-for") || "127.0.0.1";
const now = Date.now();
const windowSize = 60 * 1000; // 60 seconds
// Get user's recent request timestamps
const userHistory = rateLimitMap.get(ip) || [];
// Filter out timestamps older than 60 seconds
const recentRequests = userHistory.filter(timestamp => now - timestamp < windowSize);
if (recentRequests.length >= 5) {
return Response.json(
{ error: "Too many requests, slow down!" },
{ status: 429 }
);
}
// Record this request
recentRequests.push(now);
rateLimitMap.set(ip, recentRequests);
// ... proceed with subscription logic ...
return Response.json({ message: "Subscribed!" });
}The Problem with "In-Memory"
This code works perfectly... on your local machine. But the moment you deploy this to a serverless environment like Vercel or AWS Lambda, it breaks.
- Statelessness: Serverless functions spin up and die. Next time the function runs, your
rateLimitMapis empty. - Multiple Islands: If your app scales to handle traffic, you might have 50 different instances running at once. Instance A doesn't know that the user just hit Instance B 500 times.
So, how do we share this "memory" across thousands of short-lived server instances?
The Solution: Redis (Upstash)
To solve this, we need an external memory—a database that is fast enough to check "has this IP made 5 requests?" in milliseconds. Redis is the standard tool for this.
For serverless apps, I love using Upstash. It's Redis over HTTP, so you don't need to manage persistent connections.
Setup
Let's clean up our naive code. First, install the specialized library:
npm install @upstash/ratelimit @upstash/redisGrab your UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN from the Upstash Console and add them to your .env file.
Implementation
Now, protecting our /api/subscribe endpoint is incredibly simple. We don't need to write logic for sliding windows or timestamps; the library handles it.
// app/api/subscribe/route.ts
import { NextResponse } from "next/server";
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
import { headers } from "next/headers";
// Create a unified limiter instance
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(5, "60 s"), // 5 requests per 60s
analytics: true,
});
export async function POST(request: Request) {
// Use IP as key (or user ID if logged in)
const ip = headers().get("x-forwarded-for") || "127.0.0.1";
// Ask Redis: "Can this guy pass?"
const { success } = await ratelimit.limit(ip);
if (!success) {
return NextResponse.json(
{ error: "Too many requests. Please try again later." },
{ status: 429 }
);
}
// ✅ Safe to process the subscription
const body = await request.json();
// Example subscription logic
console.log(`Subscribing email: ${body.email}`);
return NextResponse.json({ message: "Successfully subscribed!" });
}So far we've set up the limiter and applied it to a route. This moves the state to Upstash, so it doesn't matter if your API is running on one server or a thousand Lambda functions across the globe.
Summary
Rate Limiting is not just a "nice to have" feature; it's a fundamental security practice.
- We learned that functional validation (regex) isn't enough to protect resources.
- We saw why in-memory solutions fail in modern serverless architectures.
- We built a robust, distributed rate limiter with Redis.
Challenge
Now that you have this working, try to extend it:
- Can you create different limits for free vs. premium users? ($user.plan === 'pro')
- Try blocking specific IPs permanently if they exceed a "ban" threshold.
Happy coding!
Real Software. Real Lessons.
I share the lessons I learned the hard way, so you can either avoid them or be ready when they happen.
Join 13,800+ developers and readers.
No spam ever. Unsubscribe at any time.