Skip to content
Tech Stack19 June 2026 · 8 min readUpdated 30 September 2026

Rate Limiting Patterns for SaaS APIs in 2026

Token bucket, fixed window or sliding window matters less than the bucket key, the order of your checks and what happens when Redis is down. Sourced patterns for a Next.js SaaS API.

Rate Limiting Patterns for SaaS APIs in 2026

What you'll learn

  • Which token bucket rate limiting setup suits a SaaS API?
  • Should you rate limit per tenant or per IP?
  • How do you add Upstash rate limiting in Next.js?
Article contents

You are about to put a limit in front of a SaaS API, and the first decision isn't the algorithm. It's the key you count against, the order your checks run in and what happens when the counter store stops answering. Start by writing down the three answers for your product: which key, which order, which failure mode. The code follows from those.

Rate limiting matters commercially, not only defensively. In Postman's 2025 State of the API report, 65% of organizations say they generate revenue from their APIs. If your API is part of what customers pay for, a limit that throttles the wrong customer is a product bug.

For reference, the Callidus case study documents 30/100-per-hour rate limits on its Calia AI assistant, a separate limit from the rest of the application. Callidus is a React and Firebase SaaS, so the code below is a Next.js and Redis sketch of the same ideas, not a copy of that system.

Which token bucket rate limiting setup suits a SaaS API?

Token bucket is a sound default for a developer-facing SaaS API. Upstash's docs say it smooths bursts into a constant processing rate and allows higher initial bursts, while fixed window fits coarse quotas such as billing periods.

Each request consumes one token, and the bucket refills at a fixed rate up to a maximum. A client that has been idle can send a short burst without being refused, yet its long-run average stays capped. The Upstash algorithm docs list the trade-offs of the three algorithms their library supports:

AlgorithmUpstash's stated advantagesUpstash's stated drawbacksMy suggested use
Fixed windowVery cheap in data size and computationBursts at window boundaries can leak through; stampedes when a new window startsBilling quotas and other coarse periods
Sliding windowSolves the boundary problemMore storage and computation; an approximation that assumes uniform flow in the previous windowAuth endpoints and other sensitive routes
Token bucketSmooths bursts, allows higher initial burstsExpensive in computationDefault for interactive, developer-facing APIs

The last column is my recommendation, not a statement from the docs.

Why is fixed window risky at the window boundary?

Fixed window counts requests inside calendar-aligned windows, so a client can spend one full allowance at the end of a window and another at the start of the next. Upstash lists this boundary leak as its main drawback.

Two concrete panels side by side with electric cyan light bleeding through the narrow vertical gap between them, representing the exploitable window boundary

Take a limit of 100 requests per minute as a worked example. A client sends 100 requests at 12:00:59 and another 100 at 12:01:00. Both windows see 100, both pass, and your backend has handled 200 requests in about two seconds. If your API exposes a reset time to clients, that boundary is easy to find. For a billing quota measured in days or months, a burst at the boundary matters far less than it does on an endpoint that calls an expensive downstream service.

Should you rate limit per tenant or per IP?

Limit per tenant for authenticated B2B traffic and per IP for unauthenticated routes. A tenant key gives each customer its own bucket, while an IP key groups everyone who shares an address.

MDN notes that rate limiting is typically based on a client's IP address, or can be specific to users or authenticated applications. Cloudflare's rate limiting rules show the same split by plan: on Free and Pro the counting characteristic is IP, while higher plans can count on headers, cookies, paths and JSON field values.

My reasoning for B2B, which is an inference and not a sourced fact: employees of one customer often reach you through a shared office or proxy address, so an IP key can throttle a whole company because of one busy script. A tenant key avoids that. Keep the IP key for login, password reset and other routes where no tenant is known yet.

Add the endpoint to the key so expensive routes get their own allowance:

  • tenantId:default for ordinary reads and writes.
  • tenantId:ai or tenantId:reports for compute-heavy routes, with a lower limit.
  • tenantId:plan only if limits vary by subscription tier.

Consider a hypothetical two-tier product. Starter tenants get a lower per-minute allowance and Enterprise tenants a higher one, each in an independent bucket. A data export by one tenant then can't eat the headroom that another tenant's dashboard needs. The numbers are yours to choose, which is why I don't print any as a recommendation.

How do you add Upstash rate limiting in Next.js?

Install @upstash/ratelimit and @upstash/redis, set the two REST environment variables, create a limiter with Ratelimit.tokenBucket and call limit() with your key. Return 429 with Retry-After when success is false.

The steps:

  1. Run npm install @upstash/ratelimit @upstash/redis.
  2. Set UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN, which the Upstash getting started page lists.
  3. Create one limiter per route group, each with its own prefix.
  4. Derive the tenant key from a verified session, never from a header the client controls.
  5. Return 429 on success: false and set the rate limit headers.

Upstash describes its library as connectionless and HTTP based, built for serverless and edge runtimes where HTTP is preferred over TCP. That's a design preference, not a hard block. Cloudflare's TCP sockets docs show that Workers can open outbound TCP connections through connect(), so "edge runtimes can't use Redis" is no longer a safe generalization.

One naming change affects the file you put this in. The Next.js docs now say the middleware file convention is deprecated and renamed to proxy, starting in v16.0.0, and that Proxy defaults to the Node.js runtime. The proxy.js reference also says Proxy runs on every request unless you add a matcher. On Next.js 15 the same code lives in middleware.ts with export function middleware.

import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";

const redis = Redis.fromEnv();

const limiters = {
  default: new Ratelimit({
    redis,
    // tokenBucket(refillRate, interval, maxTokens)
    limiter: Ratelimit.tokenBucket(100, "1 m", 200),
    prefix: "rl:default",
  }),
  ai: new Ratelimit({
    redis,
    limiter: Ratelimit.tokenBucket(30, "1 h", 30),
    prefix: "rl:ai",
  }),
};

export async function proxy(request: NextRequest) {
  // Placeholder: derive this from your verified session, not a client header.
  const tenantId = await getVerifiedTenantId(request);
  const isAi = request.nextUrl.pathname.startsWith("/api/ai");
  const limiter = isAi ? limiters.ai : limiters.default;

  const { success, limit, remaining, reset } = await limiter.limit(tenantId);

  if (!success) {
    return new NextResponse("Too Many Requests", {
      status: 429,
      headers: {
        "Retry-After": Math.max(1, Math.ceil((reset - Date.now()) / 1000)).toString(),
        "X-RateLimit-Limit": limit.toString(),
        "X-RateLimit-Remaining": "0",
        "X-RateLimit-Reset": reset.toString(),
      },
    });
  }

  const response = NextResponse.next();
  response.headers.set("X-RateLimit-Remaining", remaining.toString());
  return response;
}

export const config = { matcher: "/api/:path*" };

The Upstash methods page says reset is a Unix timestamp in milliseconds, which is why the code subtracts Date.now() and divides by 1000. The limits in the snippet (100 per minute with a burst cap of 200, and 30 per hour for AI routes) are placeholders for you to tune.

Use 429, not 503. MDN defines 429 as the client having sent too many requests in a given time and says a Retry-After header may be included. 503 means the server isn't ready to handle the request. Retry-After itself is defined in RFC 9110 as either an HTTP date or a delay in seconds.

A note on the X-RateLimit-* headers: they are common but not standardized. The IETF HTTPAPI working group's RateLimit header fields draft, which was still an active Internet-Draft (version 11, May 2026) as of September 2026, defines RateLimit and RateLimit-Policy instead and describes the X-RateLimit-* names as inconsistent across vendors. Pick one scheme and document it.

Upstash's pricing page lists a free tier with 500K commands per month and pay-as-you-go pricing of $0.2 per 100K commands at the time of writing. Check it before you size your usage.

What happens when Redis is unreachable?

Decide per route whether a limiter outage should allow or refuse traffic. Upstash's library has a built-in answer: an optional timeout, after which the request is allowed to pass, which means it fails open by default.

The Upstash features page says the timeout defaults to 5 seconds and that the response includes a reason field. Log that field so you can see when requests passed only because of a timeout. My recommendation, as an opinion: lower the timeout for user-facing routes so a slow counter doesn't add five seconds of latency, and consider failing closed on login endpoints.

If you'd rather not add Redis yet, a Postgres fixed-window counter is a workable stopgap for a low-traffic API. It needs a unique constraint on bucket_key:

INSERT INTO rate_limit_counters (bucket_key, count, window_start)
VALUES ($1, 1, date_trunc('minute', NOW()))
ON CONFLICT (bucket_key) DO UPDATE
  SET count = CASE
    WHEN rate_limit_counters.window_start < date_trunc('minute', NOW())
    THEN 1
    ELSE rate_limit_counters.count + 1
  END,
  window_start = CASE
    WHEN rate_limit_counters.window_start < date_trunc('minute', NOW())
    THEN date_trunc('minute', NOW())
    ELSE rate_limit_counters.window_start
  END
RETURNING count, window_start;

This is a fixed window, so it carries the boundary drawback from the table above. I haven't load-tested it, so I won't quote a throughput figure. If your product sits behind Cloudflare, its rate limiting rules act before traffic reaches your origin, which suits coarse volume limits, while per-tenant tier logic stays in your application.

Where does the billing quota check belong?

Run the billing quota check before the rate limit and before any expensive route handler. A rate limit controls speed, a quota controls total consumption, and both have to refuse a request before it reaches a costly downstream call.

Zuplo's API cost protection guide puts the difference in numbers. A consumer who stays at 99 requests per minute under a limit of 100 still makes 142,560 requests per day. The same guide says billing alerts notify you after the damage is done and are monitoring tools, not enforcement mechanisms. That's a vendor's view, but the arithmetic holds: 99 requests times 1,440 minutes.

Consider a hypothetical free-trial tenant. Its request passes the rate limit, reaches a handler that calls an LLM or renders a PDF, and only then meets the tier check. The expensive work has already happened. The order I suggest:

  1. Authenticate the request.
  2. Check the tenant's billing quota for the current period.
  3. Apply the per-tenant rate limit.
  4. Run the route handler.

Steps 2 and 3 can share one pass if both counters live in the same store. What matters is that neither runs inside or after the expensive call. If your quota check exists only in a Stripe webhook handler or a nightly job, any request that arrives between exhaustion and the next run goes through. A check that runs on every request is the only one that closes that gap.

What key does your limiter use today? If it's the IP address on a B2B product, switch that first, then add the quota check upstream of your most expensive route.

Free resource

Free SaaS MVP Scope Template

A Notion document with the full feature checklist, MVP vs. nice-to-have table, pre-build questions, and cost signals — so you walk into any developer call knowing exactly what to ask for.

Get the template →
DL

Dusko Licanin

Full-Stack Developer · Banja Luka, Bosnia

Full-stack developer shipping SaaS MVPs, web apps, and mobile apps using AI-augmented workflows — without agency coordination overhead. Live portfolio: BookBed, Callidus, Pizzeria Bestek.

Frequently Asked Questions

What is token bucket rate limiting and when should I use it?

Token bucket is a rate limiting algorithm in which each request consumes a token from a bucket that refills at a fixed rate up to a maximum. Upstash's documentation says it smooths bursts into a constant processing rate and allows higher initial bursts, at the cost of more computation. It is a sound default for interactive, developer-facing SaaS APIs. Fixed window suits coarse quotas such as billing periods, while sliding window suits sensitive routes such as authentication, where boundary bursts matter more. Tune the refill rate and maximum to your own traffic rather than copying example numbers.

Should I rate limit per IP or per tenant in a SaaS API?

Rate limit per tenant for authenticated B2B traffic, and per IP for unauthenticated routes such as login. A tenant key gives each customer an independent bucket, while an IP key groups everyone behind a shared address, which can throttle a whole company because of one busy script. That reasoning is an inference rather than a sourced fact. Add the endpoint to the key, for example tenantId:ai, so compute-heavy routes get their own lower allowance without restricting cheap reads. Derive the tenant from a verified session, never from a header the client controls.

How do I set up Upstash rate limiting in Next.js?

Install @upstash/ratelimit and @upstash/redis, set UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN, create a Ratelimit with Ratelimit.tokenBucket(refillRate, interval, maxTokens) and call limit() with your key. The response includes success, limit, remaining and reset, where reset is a Unix timestamp in milliseconds. Return 429 with a Retry-After header when success is false. Next.js 16 renames the middleware file convention to proxy and defaults it to the Node.js runtime, while Next.js 15 still uses middleware.ts. Add a matcher so the limiter does not run on static assets.

What should happen when the rate limiter's Redis is unreachable?

Decide per route whether to fail open or fail closed. Upstash's library has an optional timeout, default 5 seconds, after which a request is allowed to pass, so it fails open by default, and the response carries a reason field you can log. For user-facing routes, consider a shorter timeout so a slow counter does not add latency. For login and password reset, consider failing closed. A Postgres fixed-window counter can act as a stopgap for a low-traffic API, but it inherits the window-boundary drawback of fixed window.

Should I check billing quota before or after the rate limit?

Check billing quota before the rate limit, and both before any expensive handler. A rate limit controls speed and a quota controls total consumption, so a client can pass every rate check and still exhaust a monthly allowance. Zuplo's guide gives the arithmetic: staying at 99 requests per minute under a limit of 100 still produces 142,560 requests per day. If the quota check runs inside the handler, or only in a webhook or nightly job, an expensive call such as an LLM request can run before the tenant is refused.