Rate Limiting Your Own API: Stop One Bad Client From Taking Down Your SaaS | Deployxa

One bad client with a tight retry loop can take your whole API down. The four rate limits worth having, how to size them without a data team, and what to log.

← Back to Dispatch Articles
Engineering Log

Rate Limiting Your Own API: Stop One Bad Client From Taking Down Your SaaS

One bad client with a tight retry loop can take your whole API down. The four rate limits worth having, how to size them without a data team, and what to log.

The ticket says "your API is slow." The dashboard says something stranger: one of your smallest instances is pinned at full CPU, latency on every endpoint has quadrupled, and the request log is a wall of the same path repeated thousands of times a minute. Upstream, a customer's brand-new integration hit a 500 error, decided that meant "try again immediately," and has been retrying in a tight loop ever since — every 500 you return buys you another burst. Meanwhile every other tenant waits in line behind the flood: their checkouts, their dashboards, their webhooks, all paying for a client you never throttled.

Key Facts

  • What Rate Limiting Actually Does: A rate limiter does two things and nothing more: it counts requests per key per window, then it decides — allow, or reject with a 429.

  • The Four Limits Worth Having: API Rate Limiting Best Practices for SaaS: You do not need a complete platform before this starts helping.

  • Choosing a Limit Without a Data Science Team: Founders stall on rate limiting because picking "the right number" feels like a statistics problem.

  • Where to Enforce It — and the Multi-Instance Trap: For a small SaaS the answer is almost always middleware at the edge of your app : the limiter runs before routing reaches handlers, before authentication work and database queries, so a rejected request costs almost nothing.

The instinct is to blame the customer, and their retry loop is admittedly badly built. But the structural problem is yours: nothing in your stack caps how much of your capacity any single client may consume. That cap is a rate limiter, and it is not an anti-customer feature. It is fairness — a guarantee that every tenant gets its share of the machine — and blast-radius control — a guarantee that one bad client can only take down itself.

This guide is the plain-language version of API rate limiting best practices for SaaS: what a rate limiter actually does, the four limits worth having and the order to add them, how to choose numbers without a data science team, where to enforce them when you run more than one instance, the retry-storm dynamic that turns your own 500s into a flood, what to log and alert on, how to tell abuse from a bug, and a fictional incident walkthrough that ties it all together.

What Rate Limiting Actually Does

A rate limiter does two things and nothing more: it counts requests per key per window, then it decides — allow, or reject with a 429. A key is whatever identity you attach to a request: an API key, a user ID, a tenant ID, sometimes an IP for anonymous traffic. A window is a slice of time — often a minute, sometimes an hour. Everything else is implementation detail.

Two properties make this worth the effort. First, it acts before your expensive work runs: middleware at the entrance can reject a request for the cost of a counter increment instead of a database query, an export, or a login attempt. Second, it is per-key, so one client's cap never touches another client's allowance — the isolation that keeps multi-tenancy survivable under load.

Equally important is what it is not. It is not authentication. It is not a firewall — it does not inspect payloads or judge intent. And it is not, by itself, a distributed denial-of-service defense. What a rate limiter is: the simplest tool that bounds the blast radius of any single client. Think of it as a bouncer with a clicker — no interrogation, just counting.

The Four Limits Worth Having: API Rate Limiting Best Practices for SaaS

You do not need a complete platform before this starts helping. Add the four limits below in order — each is simpler than the problem it prevents, and each catches what the previous one cannot.

1. A global cap per instance

The crudest limit is also the cheapest: if an instance receives more requests per window than it can plausibly serve, shed the excess with a 429 before queues build. One counter, no identity required. This is a last-line guard for the machine itself — it will not be fair, because rejection is effectively arbitrary — but it keeps a surge from melting an instance and taking every tenant down with it.

2. Per-API-key and per-user limits

The fairness layer. Every key gets its own budget per window. This is the limit that matters most for a multi-tenant SaaS, because it converts "one client can consume everything" into "one client can consume its share." It is also your fastest containment tool — tightening one key's budget is the handle you turn during incidents.

3. Stricter per-endpoint caps for expensive routes

A flat per-key limit quietly assumes every request costs the same. Yours do not. An export that generates a 50,000-row file, a report that runs a heavy query, a login attempt, a password reset — these cost ten to a hundred times an ordinary read, and some of them are also the routes abuse probes first. Give expensive routes their own, tighter budgets that apply on top of the general per-key limit. As a bonus, a strict cap on login and password-reset doubles as brute-force protection: one limit, two jobs.

4. Retry-aware responses: 429 with Retry-After

A limit that rejects badly invites the flood straight back. A bare rejection — or worse, a 500 for over-limit traffic — tells the client "try again now," which recreates the storm you just stopped. Return 429 with a Retry-After header so well-behaved clients know exactly how long to wait:

HTTP/1.1 429 Too Many RequestsRetry-After: 17X-RateLimit-Limit: 600X-RateLimit-Remaining: 0X-RateLimit-Reset: 42

Those values are illustrative, but the headers are cheap to send and disproportionately useful: they let integration authors build against your real numbers instead of discovering them through errors.

Choosing a Limit Without a Data Science Team

Founders stall on rate limiting because picking "the right number" feels like a statistics problem. It is arithmetic. Three moves get you defensible numbers in an afternoon.

Start from your slowest endpoint's cost. Time your most expensive common endpoint — the export, the report. If it takes two seconds of real work and one instance provides, say, thirty seconds of that work per minute across all clients, then a single client hammering that endpoint twenty times a minute consumes the entire instance by itself. Set the expensive-route cap well below that line. The general per-key limit can be far more generous, because ordinary requests are cheap.

Do the per-tenant fairness math once. Illustrative, not magic: if your fleet comfortably serves 600,000 ordinary requests a minute and you have 300 active tenants, a generous default is 600,000 ÷ 300 = 2,000 requests per tenant per minute. Round down, write it into config, and adjust when real usage tells you something. Most tenants will sit far below the cap — the cap only binds for the client having a very bad day.

Make limits config, not code. The numbers will change: traffic grows, endpoints get heavier, incidents demand a temporary squeeze. Keep every limit in one config surface — environment variables or a settings file — read by one limiter. A change that is a config edit plus a redeploy actually gets made; a change that is a code hunt through twelve handlers does not.

Tell customers the numbers. Publish limits in your docs and return them in response headers. A limit a customer knows is a feature they integrate against; a limit they discover is a support ticket and a dent in trust. The worst version of rate limiting is silent rate limiting.

Where to Enforce It — and the Multi-Instance Trap

For a small SaaS the answer is almost always middleware at the edge of your app: the limiter runs before routing reaches handlers, before authentication work and database queries, so a rejected request costs almost nothing. Putting limits in front — at a reverse proxy or gateway — rejects traffic even earlier, which sounds better but has a catch: per-key limits need identity, and identity (API keys, sessions) usually lives inside your app. In front of the app, you can enforce only coarse anonymous limits like per-IP caps. Most small teams end up with a coarse guard at the front if their stack makes it easy, and the real per-key work in middleware. That split is fine. What is not fine is the trap:

In-memory counters only work per-process. The naive limiter keeps a dictionary in the app's own memory. The moment you run two instances — and you will, for load or simply during a blue/green deploy window — you have two independent counters, and your advertised limit is effectively doubled. Uneven load balancing makes it worse: one instance's counter fills while the other sits idle.

Once anything runs more than one process, the counter needs a shared store: Redis, your own database with an atomic increment, whatever your stack already operates. The requirement is provider-neutral and non-negotiable: one counter per key per window, readable and writable by every instance, incremented atomically. The shape of it:

# Illustrative pseudo-code — a fixed-window counter, not production codekey = api_key or user_id # the identity you countbucket = "rl:" + key + ":" + current_minute()count = SHARED_STORE.increment(bucket) # atomic; shared by ALL instancesif count == 1: SHARED_STORE.expire(bucket, 60) if count > limit_for(route, key_tier): respond 429, Retry-After: seconds_left_in_windowelse: pass through to the handler

Fixed windows have a known edge effect — a client can burst across the boundary of two windows — and sliding-window or token-bucket variants smooth that out. Start with the fixed window anyway: a slightly generous fixed window beats a perfect algorithm you never ship.

The Retry Storm: How Your 500s Become a Flood

Here is the dynamic behind the opening scene. A client gets a 500. Its retry logic — or absence of one — fires immediately. Your service, now busier, fails again, generating more retries. Load rises exactly when capacity is lowest, which is why these incidents feel like the service fighting itself: one misbehaving client with a tight loop can multiply a single slow endpoint into a flood that drowns every tenant not participating.

Two ideas from the client side defuse this, and they are worth knowing well enough to paste into your integration guide. Exponential backoff means each retry waits twice as long as the last — one second, then two, then four — so repeated failures spread out instead of stacking. Jitter adds randomness to each wait so a thousand clients that failed at the same moment do not all retry in a synchronized wave. Together they turn a stampede into a trickle that decays.

Your side of the bargain is one status code. Return 429 for over-limit traffic, never 500. A 429 with Retry-After tells a well-behaved client to stop and wait; a 500 tells it that you broke and it should try again — precisely the amplification you are trying to break. And fix the underlying 500s too: the limiter slows the storm, but the bug that seeded it is still yours.

What to Log and Alert On

A limiter you cannot observe is a limiter you can neither tune nor trust. Three cheap habits cover it.

Log every 429 with identity. Key or tenant ID, endpoint, timestamp, remaining budget. "Who hit the limit" is the first question of every one of these incidents, and the log answers it in seconds.

Watch the 429 rate per key. A low, steady background hum of 429s is normal and healthy — it means the caps exist and occasionally bind. What you act on is change: one key going from zero to thousands per minute, or many keys rising together.

Alert on spikes — and on sudden drops. The counterintuitive one: a limiter that suddenly produces zero 429s is either serving no traffic (check that first) or broken — a deploy bypassed the middleware, or the shared store is down and the limiter is failing open. A silent limiter during live traffic deserves the same alarm as a screaming one.

Signal

What it looks like

Likely meaning

First move

One key's 429s spike

Near zero to thousands/min on a single key

Stuck retry loop or hammering

Tighten that key's cap; contact its owner

Many keys' 429s rise together

Fleet-wide rise over hours or days

Traffic growth, bots, or limits now too tight

Compare to traffic trend; review recent limit changes

429s fall to zero

Silent limiter during live traffic

Limiter bypassed or failing open

Check middleware and shared-store health

429s cluster on one endpoint

Only the export or login route

Expensive route under load, or probing

Tighten that endpoint's cap

429 spike right before a 5xx spike

One key's 429s, then errors everywhere

Retry storm in progress

Cap the key; find and fix the seeding 500

Abuse or Bug? Telling Them Apart

Almost every rate-limiting incident is one of two shapes, and the right response differs.

One token hammering. A single key — or a handful — at a sustained, conspicuously high rate, usually concentrated on one endpoint. This is either a broken integration or deliberate abuse, and one signal separates them: does the client respect your 429s? A client that backs off when told has a bug worth fixing together with its owner. A client that ignores Retry-After and keeps hammering is a loop with no backoff, or someone leaning on your API — treat it as abuse and cap it hard. The distinction matters: the first gets a friendly email, the second gets a permanently small budget.

Many tokens crawling. The harder pattern: hundreds of keys, each staying politely under its own limit, all doing suspiciously similar work — same endpoints, similar pacing, recently created accounts. No individual key alarms you; only the aggregate does. This is why per-key logs alone are not enough: watch per-endpoint and per-account-creation signals too, because the pattern lives in the correlation, not in any single client.

Pattern

What you see

Most likely cause

One token hammering

Single key, high sustained rate, one endpoint

Broken integration loop — or abuse if it ignores 429s

Many tokens crawling

Many fresh keys, each under its limit, similar behavior

Scraping, trial farming, or credential probing

An Incident Walkthrough: The Partner Deploy That Started a Retry Storm

This walkthrough is fictional, assembled from common failure patterns, but every step runs on machinery you can build this month.

A partner ships a new version of their connector on a Tuesday. Their connector calls one of your endpoints with a deprecated parameter — and a validation bug you shipped last week turns that into a 500 instead of a clean 400. Their connector has no backoff: on any 500 it retries instantly, forever. Within twenty minutes, several hundred of their customer accounts are running retry loops, each generating twenty requests a minute, all aimed at one endpoint.

Detection. Your 429-per-key alert fires: one partner's keys have gone from zero to tens of thousands of 429s an hour, and the log shows their requests arriving with metronome regularity — the signature of a machine, not a person. The 5xx curve tells the rest: the storm started right after their deploy, seeded by your 500.

Containment. Because limits are config, you set a temporary, much stricter cap on that partner's keys — a squeeze, not a block, so legitimate traffic still trickles through. The instance stops melting, and other tenants' latency recovers within minutes.

Fix. Two bugs, two owners. You roll the validation change back, which turns the 500s into clean 400s the connector does not retry; the loops exhaust themselves. The partner adds backoff with jitter to their retry path. Neither fix depends on the other, which is why doing yours first works.

Permanent change. The temporary cap on that partner's keys becomes a standing per-key tier in config. Every 429 now carries Retry-After, and your integration guide gains a backoff paragraph. Total customer-visible damage: one slow hour for one partner's tenants, instead of a dead afternoon for everyone.

The point of the story is not the plot. It is that every step ran on infrastructure built in peacetime — per-key logs, config-based caps, an alert on 429 spikes. Build those during a quiet week, and the incident becomes paperwork.

The Rate-Limiting Checklist

  • [ ] Every request path passes through the limiter — including health checks, webhooks, and the internal route you forgot about
  • [ ] Every API key and user has an explicit per-window budget, with a written default for new keys
  • [ ] Expensive routes (export, reports, login, password reset) carry their own tighter caps on top of the general limit
  • [ ] Over-limit traffic gets a 429 with Retry-After and rate-limit headers — never a 500 and never silence
  • [ ] Counters live in a store shared by every instance, verified with two instances actually running — not assumed
  • [ ] All limit numbers live in one config surface and can be tightened without a code hunt
  • [ ] Every 429 is logged with key, endpoint, and count, with enough history to compare this week to last
  • [ ] Alerts exist for 429 spikes and for sudden drops to zero — and both have been tested on purpose
  • [ ] You can tell one-key hammering from many-token crawling from your logs in under five minutes
  • [ ] Your docs publish the limits and the 429 behavior, so customers integrate against known numbers

Where Deployxa Fits — and Where It Doesn't

Most of this article is deliberately platform-agnostic, because rate limiting is application code: the middleware, the counters, the config, the 429s are yours to build and maintain, on any platform. Deployxa does not provide a WAF or a per-tenant throttling service, and no hosting platform removes that responsibility from you.

What the platform contributes is the surrounding machinery that decides how bad one of these incidents gets. Releases deploy into a standby slot and are health-verified before traffic switches — a health-gated deployment, which is exactly the mechanism that stops a release carrying a storm-seeding bug from ever taking customer traffic. When an unreleasable change ships anyway, the prior healthy release stays warm, so rollback is fast — sub-second inside the warm window — and the flood loses its source. And because per-deployment logs and metrics are visible from the dashboard, you can see which release's 500s seeded a storm and watch the 429 and 5xx curves respond as you tighten caps, without SSH. The deployment mechanics are part of the product suite, documented in the docs; the limits themselves remain your application's code.

So here is the one action that matters. First, spend an hour turning the four limits into config behind your middleware. Then rehearse the failure path in a safe environment: on a non-production project, deploy a release you already know is broken, watch the health gate refuse it traffic, and practice a rollback while the prior release is still warm. A rehearsal on a staging project costs minutes; discovering your rollback works only during a real retry storm costs an outage.

Ready to deploy with Deployxa?

Deploy your apps globally with automatic SSL and AI diagnostics.

Start Free Now