Rate Limiting for SaaS APIs: A Practical Guide
Rate Limiting for SaaS APIs: A Practical Guide
Your SaaS app is live. Customers are using it. Everything's fine — until someone writes a script that hammers your API 10,000 times per minute, your PDF endpoint falls over, and every other tenant on the platform feels it.
Rate limiting is one of those things that feels optional until it isn't. For multi-tenant SaaS, it's table stakes. One tenant's runaway integration shouldn't take down the platform for everyone else.
This guide covers how to think about rate limiting for B2B SaaS apps, the algorithms that actually matter, and how to implement tiered limits without making your codebase a mess.
Why Rate Limiting Matters More in Multi-Tenant SaaS
Single-tenant apps have a natural blast radius: if one customer goes wild, they only hurt themselves. Multi-tenant apps don't have that luxury. Every tenant shares the same infrastructure — the same database, the same API servers, the same third-party service quotas.
Without rate limiting, you're vulnerable to three things:
- The noisy neighbor problem. One tenant's bulk operation saturates your API, adding latency for everyone else.
- Abuse and scraping. Public-facing endpoints (customer portals, shared links, webhooks) are exposed to the internet. Bots will find them.
- Cascading failures. An unthrottled burst can exhaust your database connection pool, crash a downstream service, or blow through your email provider's sending limits.
Choosing the Right Algorithm
There are four common rate limiting algorithms. For most SaaS apps, you only need to think about two.
Fixed Window
Count requests per calendar window (e.g., 0:00–0:59, 1:00–1:59). Simple, but has a burst problem: a user can send 100 requests at 0:59 and another 100 at 1:00, effectively doubling the limit across a 2-second span.
Sliding Window
Tracks requests across a rolling time period. If your limit is 30 requests per minute, it always looks at the last 60 seconds — not a fixed clock boundary. This eliminates the burst problem and gives users a smooth, predictable experience.
For production SaaS APIs, sliding window is the right default. It's what Cloudflare recommends and what most rate limiting libraries implement.Token Bucket and Leaky Bucket
These algorithms are more relevant for infrastructure-level throttling (load balancers, API gateways) than for application-level rate limits. If you're implementing rate limiting inside your app code, sliding window gives you the best balance of fairness and simplicity.
Designing Rate Limit Tiers
Not all requests are created equal. A static "100 requests per minute for everyone" policy will either be too generous for expensive operations or too restrictive for cheap ones.
Design your tiers around two dimensions: who's making the request and how expensive it is.
Here's a practical tiering model:
| Tier | Limit | Identifier | Used For |
|---|---|---|---|
| Public | 20 req/min | IP address | Unauthenticated reads (shared links, public pages) |
| Public Heavy | 5 req/min | IP address | Expensive unauthenticated operations (PDF generation) |
| Authenticated | 30 req/min | Tenant ID | Standard authenticated operations (CRUD, email sends) |
| Webhook | 100 req/min | IP address | Inbound webhooks from trusted services (Stripe, etc.) |
Why Tenant ID, Not User ID?
For B2B SaaS, rate limiting by tenant ID is almost always better than by user ID. Here's why:
- A 10-person team doing normal work shouldn't hit limits designed for one person.
- But a single tenant's integration or automation shouldn't get 10x the limit just because it authenticates as different users.
- Tenant-level limits match your infrastructure isolation boundaries — you're protecting shared resources per tenant, not per seat.
Implementation: The Guard Pattern
The cleanest way to add rate limiting to an existing API is a guard function — a function that returns an error response if the request is rate limited, or null to proceed. This mirrors other guards you probably already have (auth checks, subscription gates).
Here's the pattern in pseudocode:
function checkRateLimit(request, tier, identifier):
limiter = getLimiterForTier(tier)
result = limiter.check(identifier or request.ip)
if result.rejected:
return Response(429, {
error: "Too many requests",
headers: {
"Retry-After": result.resetTime,
"X-RateLimit-Limit": result.limit,
"X-RateLimit-Remaining": result.remaining
}
})
return null // proceed
Then in each route handler, it's two lines:
rateLimited = checkRateLimit(request, "authenticated", tenant.id)
if (rateLimited) return rateLimited
// ... rest of the handler
This approach has three advantages over middleware-based rate limiting:
- Per-route tier assignment. Your PDF endpoint gets a stricter limit than your list endpoint.
- Access to route context. Authenticated routes can rate limit by tenant ID, which isn't available until after auth checks run.
- Explicit and visible. Every rate-limited route is marked as such in the code. No hidden middleware magic.
The Fail-Open Principle
Here's the rule that will save you at 2 AM: if your rate limiter is down, requests should proceed normally.
Rate limiting typically depends on an external store (Redis, a managed service). That store will, at some point, be unreachable — a network blip, a provider outage, a misconfigured connection. When that happens, you have two choices:
- Fail closed: Block all requests until the rate limiter recovers. Your app is now down because your rate limiter is down.
- Fail open: Let requests through unthrottled. You lose protection briefly but the app keeps working.
null (proceed) on any error.
The exception: if you're protecting a truly critical security boundary (login attempts, payment processing), consider a stricter failure mode for those specific routes.
Redis as the Rate Limit Store
Rate limiting needs a shared counter that all your API servers can access with sub-millisecond latency. That means an in-memory store, and for most teams, that means Redis.
You have two options:
Self-Hosted Redis
Full control. More operational overhead. You're responsible for availability, replication, and persistence.Managed Redis (Upstash, Redis Cloud, ElastiCache)
Less control. Zero operational overhead. Upstash in particular offers a serverless model with per-request pricing — no idle costs, and it works well with serverless deployments (Vercel, Netlify, Cloudflare Workers).For serverless SaaS apps, managed Redis is the practical choice. Upstash's @upstash/ratelimit library provides sliding window rate limiting out of the box, so you're not implementing the algorithm yourself.
What to Return on 429
When a request is rate limited, your response should include three things:
- A clear error message. "Too many requests. Please try again later." Not a generic 500 error.
- A
Retry-Afterheader. Tells the client exactly how long to wait. Well-behaved API consumers (SDKs, integrations) will respect this automatically. - Rate limit headers.
X-RateLimit-Limit(the cap) andX-RateLimit-Remaining(how many are left) let developers build smart backoff logic.
Monitoring and Alerting
Rate limiting without monitoring is flying blind. Track at least these metrics:
- 429 response rate by tier. If your public tier is returning 429s constantly, the limit is probably too low — or you're being scraped.
- Rate limit utilization. What percentage of the limit are tenants actually hitting? If most tenants use less than 10% of their allocation, you've set the limits too high. If they regularly hit 80%+, too low.
- Redis latency. Your rate limit check adds a network round-trip to every request. If Redis latency spikes, you'll feel it across your entire API.
Common Mistakes
Rate limiting only public endpoints. Authenticated routes need limits too. A customer's misconfigured integration can do just as much damage as a bot. Using the same limit for reads and writes. A GET that returns cached data and a POST that generates a PDF have wildly different costs. Tier them separately. Forgetting about webhooks. Inbound webhooks from services like Stripe can come in bursts (e.g., during a batch subscription update). Set webhook limits high enough to avoid dropping legitimate events, but low enough to stop replay attacks. Hard-coding limits. Use environment variables or a config file. You will need to adjust limits in production, and you don't want to redeploy to change a number. Not rate limiting during development. Test with rate limiting enabled locally. Finding out your limits are wrong in production is expensive.How Offerframe Handles Rate Limiting
At Offerframe, we implemented rate limiting across all 10 of our API route groups using the tiered model described above. Public quote links and PDF generation get stricter limits (5-20 req/min per IP), authenticated operations like email sends and QuickBooks sync get 30 req/min per tenant, and Stripe webhooks get 100 req/min.
We use Upstash Redis with a sliding window algorithm and fail-open design — if Redis is unreachable, requests proceed normally. The entire implementation adds 2-3 lines per route and a single shared module.
For a multi-tenant quoting platform where one tenant's PDF burst shouldn't slow down another tenant's dashboard, this tiered approach keeps things fair without adding complexity to the developer experience.
If you're building a quoting or proposal workflow and want a platform that handles this infrastructure for you, check out Offerframe's pricing -- flat $149/mo for up to 10 users, every feature included. You can learn more about our full security posture, including encryption, access controls, and compliance.
Key Takeaways
- Use sliding window for accurate, burst-resistant rate limiting.
- Tier by cost and caller type — not one-size-fits-all.
- Rate limit by tenant ID for authenticated routes, IP for public ones.
- Fail open. A rate limiter outage shouldn't become an app outage.
- Return proper 429 responses with
Retry-Afterand rate limit headers. - Monitor actively. Track 429 rates, utilization, and Redis latency.
Building a B2B quoting platform? Offerframe gives you professional quotes with flat pricing, zero setup fees, and the infrastructure to scale. Join the beta.