Skip to content
ArchitectureInsights

When to rate-limit — and what you are actually capping

You have rate-limited when the expensive call has a cap and the 429 has an owner. A WAF without a 429 is a hope.

Rate limiting is a cap on the expensive call — login, search, webhooks — not a firewall around the whole site. Set it when abuse is cheaper than your origin, and own the 429 you show.

15 min read
Stylised system diagram with an API seam and a clear queue in front of login and webhooks — a cap, not a firewall around the whole site.

Architecture & API

If you are searching for rate limiting api, or for protect api, you are usually here: someone put a form, a login or a webhook endpoint on the internet, and you are unsure whether that counts as 'protected'. Someone sets a Cloudflare rule on the whole hostname and calls it done. Someone rate-limits 'all traffic' and discovers the homepage now 429s while login is still free. Someone has a middleware that counts IPs in memory across three serverless instances that do not share a counter. Someone refuses to touch anything until they have an identity layer, and lets search run unbounded in the meantime.

The security post is about headers, updates and the admin window you forgot to close. This one is about the cap on the expensive thing: the call that hashes a password, that hits your search index, that writes an order, that calls a model. An honest rate limit is not 'protect the API'. It is a sentence about which job may cost, how often, and what the user sees when the cap is hit. Without that sentence you have a rule. You do not have a defence.

TL;DR

The main points

  • Limit the expensive call — login, search, webhooks, AI, checkout — not 'all traffic' as if the homepage were the attack.
  • A WAF and an app 429 are two jobs. The CDN stops the crude flood. The app owns what needs your data.
  • The counter has to be shared. An in-memory limit on three serverless instances is three holes you call one cap.
  • A 429 should say when to try again — Retry-After, an honest UI, not a 500 or a blank page.
  • Identity beats IP when you have it. A NAT behind an office is not a crowd of attackers. An expired session is not innocence either.
  • Set the cap when abuse is cheaper than your origin — not when someone said 'we need rate limiting' on a slide.

Why this even matters

An endpoint without a cap is an invoice you did not sign. Login hashes. Search reads. Webhooks write. A model costs per token. A scraper, a stuffed login, a 'try every company number' script, a webhook you called in a loop — same class: someone found the expensive thing, and you pay until you say no. The cap is not paranoia. It is refusing to subsidise the work you would not run by hand.

Teams inherit the words rate limiting from another layer. In an edge incident it is 'block the country'. In an API gateway it is tokens per key. In a Next.js route it is often a middleware someone copied, that counts wrong, shows 500, and hits the wrong path. You think you have protected the API. You have moved the pain onto the homepage, or you have a cap that resets every time an instance starts. The ritual is expensive because it gives you permission not to point at the call that actually hurts.

It is also a trust and product question. A 429 without Retry-After is a failure the user blames on you. A 403 on a login that was only too fast is a lockout you cannot explain. A captcha in front of the whole site, because you would not own a cap on `/api/search`, is theatre: you punish the reader for not limiting the scraper. If nobody owns the 429 page, the cap is a network signal. It is not a product.

We write this because 'we must protect the API' has become a purchase you make before you have a sentence about which call, which identity and which 429. An honest setup is boring: caps on login, search, webhooks and whatever calls onward; a shared counter; honest status. Without that sentence you have a WAF rule with a number. You do not have rate limiting.

What rate limiting actually is — and what 'protect the API' is not

Rate limiting is a cap on a named call in a named window: this many login attempts per account and per IP per quarter-hour, this many searches per session per minute, this many webhook retries per source. It is a product choice, because it says who you think you are and what you accept to run. It is not an insult. Most public sites should have it on three paths, not on every path.

It is not 'block bots'. A WAF, a managed challenge, a bot score, is a different job: keep the crude stuff out before it hits origin. That is right, and it is not the cap. The cap is what happens when the call is already yours — a real user, a real key, a real form — and you still say no because it is too often. If you only have a WAF, you have a hope that the expensive call will not come in. You have not said no to what came in.

It is also not 'put a number on the hostname'. A global request limit hits the page you want crawled, and lets `/api/login` be what survives because it is fewer bytes. The attacker chooses the expensive thing. You limit the cheap thing. An honest cap starts with the sentence 'this call costs us in CPU, in vendor, in lockout risk' — qualitative, not dashboard theatre. Without that sentence the number is interior design.

And it is not an in-memory Map in a serverless function. Three instances, three counters, three times the cap. A deploy, and the counter is gone. Rate limiting that does not survive a new instance is a suggestion. Use something shared: KV, Redis, a platform rate limit, edge cache with a key. Pick the boring thing that counts correctly. A clever number on three holes is still three holes.

What typically goes wrong when someone says we have rate-limited

The first failure is limiting the visible, not the expensive. You cap `/` because that is what you can see in the CDN log, and let `/api/search`, `/api/auth` and the webhook route run. You have moved the graph. You have not moved the risk. An honest cap starts with the call you would not run in a loop by hand. If you cannot point at it, you are not ready for a number. You are ready to read your own routes.

The second failure is the counter you did not share. Next.js on Vercel, Cloudflare Workers, two regions, preview and prod. A Map in the module, a cookie you set yourself, an edge middleware without storage. It works in one window. It does not work when three isolates take the next three requests. You get a cap you can demo, and a hole you pay for. Shared state is the duty. Anything else is a local cap you call global.

The third failure is the status code and the body. A 500 on too many logins is a lie: you know exactly what happened. A 200 with `{ ok: false }` is a hole the client cannot tell from validation. A 403, as if the user were banned, is a lockout you cannot explain to support. 429 is the word. Retry-After is the time. The UI should say 'try again in a moment', not 'something went wrong'. The error-pages post is the screen. Here the duty is to let the cap be true in the header.

The fourth failure is buying identity theatre before you have a cap. 'We will wait for SSO, then we will limit.' Meanwhile public search and the public form are open. You can limit on IP and on cookie until you have a user. That is crude. It is better than nothing. A NAT is a problem you can absorb with a higher cap and a second signal. No identity is not a reason to let `/api/login` run unbounded.

WAF and edge vs. app 429 — two layers, two kinds of no

The honest edge layer

Stop the crude flood before it hits origin. Hostname, bot score, the known patterns.

  • WAF and managed challenge belong here — not as a substitute for an app cap
  • CDN rate limit on obvious floods: what should not be allowed to meet your code
  • Is the right first filter, and the wrong only filter, when the call needs your data
  • Sees bytes and patterns. Does not see that search is more expensive than the homepage

The honest app cap

429 on the named call, with a shared counter and a body you own.

  • Login, search, webhooks, AI, checkout — one cap per job, not one number for the whole API
  • Key: user, token, IP as fallback — and a window you can explain
  • Retry-After and a UI that says no honestly, not a 500 you call 'resilience'
  • Is waste if you still have not written which call hurts

How to decide whether you should rate-limit at all — and where

Without this order you get a hostname rule you call API protection.

  1. 01

    List the calls you would not run in a loop by hand

    Login. Password reset. Search. 'Send a quote'. Webhooks in and out. Whatever calls a model or a payments gateway. If the list is empty, you do not have a rate-limit problem. You have a 'someone said API' problem. Write the list. That is the cap. The rest of the site may stay open for crawl and for readers.

  2. 02

    Write the identity you actually have today — not the one you hope for

    A logged-in user. An API key. A cookie. An IP. A signed webhook secret. Limit on what you can point at. Do not mix 'we get SSO next quarter' into what is open today. IP is crude and hits NAT. It is still better than nothing on `/api/login`. When you have a user, limit the user, and keep IP as a coarser cap behind it.

  3. 03

    Pick a shared place to count — or stop pretending you count

    KV, Redis, a platform rate limit, an edge store with a key. Not a module variable. Not three caches you think are one. If you will not own a store, use the cap the platform already has at the edge, and be honest that it is crude. A clever app cap without shared state is a demo cap. It counts in one window. It does not count in prod.

  4. 04

    Decide the 429 before you turn the number on

    Status. Retry-After. A body the client can tell from validation. A page a human can read if it is a form. The error-pages post is chrome. Here you should not pretend a blank 500 is 'fail closed'. Fail closed on login is saying no honestly. Fail closed on the homepage is that you limited the wrong call.

  5. 05

    Give the cap a kill switch and an adjust criterion

    If the job was stuffed login, and you have MFA and lockout, the IP cap can be coarser. If the job was search hitting the database, and you have cached results, the window can open. A cap without adjustment is how you 429 your own integration tests and the real customers on the same NAT. Write who may change the number, and where you see it hitting wrong.

“You have rate-limited when the expensive call has a cap and the 429 has an owner. A WAF without a 429 is a hope.”

Login, search, webhooks and the public form — same word, four jobs

Login and password reset are the cap that is most often right because of lockout risk, not CPU. You do not want to hash a flood of guesses. You do not want to send a flood of reset mail. Identity beats IP when you have an account. IP beats nothing when you do not. An honest login cap is tight per account, coarser per IP, and a 429 that does not sound like 'wrong password'. Mix the two and you have given an attacker an oracle. The security post is headers and secrets. Here the duty is to make the guess expensive for them, not for you.

Search and typeahead are the cap that is most often right because of the origin bill. Every keystroke is a database call unless you cache. A scraper that loves your `/api/search` is not a reader. A global hostname cap hits the blog. A cap on the search route hits the job. Cache what you can. Limit what you cannot. If you 'protect the API' by requiring login to search a public catalogue, you have moved the product. You have not moved the cap.

Webhooks in and out are the cap you forget until a partner retries in a loop, or until your own job calls you. Sign the secret. Idempotency on event id. And a cap per source, so a failing handler does not become a denial you paid for. That is not 'protect the API'. That is refusing to let a loop you own look like traffic. The observability post is seeing it. Here it is saying no once you have seen it enough.

The public form — contact, 'book a conversation', newsletter — is the cap that most often becomes captcha theatre. Captcha can be right when you have a human in front of an expensive action. It is wrong as a substitute for a cap: you can limit per IP and per field-hash, hide a honeypot, and still show the form. The cookie-banner post is consent. This one is making spam expensive without punishing the person who writes three honest lines. A portal landing is what you build when the form has become a workflow — not when you just needed to say 429.

What you should typically do in the situation you are in

The left is what you typically say. The right is the next step we typically recommend.

  • Situation

    Someone said we must protect the API, and you put a WAF on the hostname

    Next step

    Keep the WAF for the crude flood. List the expensive calls and put 429 there — not a number on the homepage

  • Situation

    Login is open, and you are waiting for SSO before you limit

    Next step

    Limit per account and per IP today. SSO is identity. It is not a cap you still owe now

  • Situation

    Search hits the database on every keystroke, and you are considering captcha on the whole site

    Next step

    Cache and limit `/api/search`. Captcha on the blog is theatre for a call you would not own

  • Situation

    Middleware counts in memory, and it works in one preview window

    Next step

    It does not count in prod. Move the counter to something shared, or call it a demo cap

  • Situation

    You 429 with a 500, 'so the client retries'

    Next step

    Then you have asked for more load. 429 and Retry-After. A 500 is a lie the retry queue loves

Checklist before you call it rate limiting

If you are missing several of these, the sentence is protect the API — the setup is a hostname rule.

  • You can point at the calls you limit, and say why those ones are expensive
  • The counter survives a new instance and is shared across the instances you actually run
  • 429 is the status, Retry-After is the time, and the UI does not lie that 'something went wrong'
  • WAF and edge are one layer. The app cap is another. You have not replaced one with the other
  • Identity beats IP when you have it — and IP is still there as a coarser cap
  • You know who may open the window when it hits your own users on the same NAT

Questions we get again and again

  • When should we rate-limit an API?

    When abuse of a named call is cheaper for someone else than for you: login, search, webhooks, whatever calls onward to a model or a gateway. If the job is that someone said 'protect the API' on a slide, list the expensive paths first. Most public sites have three paths that need a cap, and a homepage that should be crawlable. Write the call. If you cannot, do not buy a number on the hostname.

  • Is a WAF the same as rate limiting?

    No. A WAF and a bot challenge stop the crude flood before it hits your code. Rate limiting is the cap on a call that is already yours — a real form, a real key, a real user hitting too often. You can have a WAF without a 429, and a 429 without a WAF. We see the first more often, and that is what lets search run unbounded behind a green 'we are protected'. Buy the edge for floods. Buy the app for the expensive call.

  • Should we limit on IP or on user?

    On what you have. User and API key beat IP when identity is there. IP is crude and hits an office behind NAT — absorb that with a higher cap and a second signal, not by dropping the cap. No identity is not a reason to leave login free. Limit per IP and per account at the same time on auth. Limit per key on an API you issued. Call it what it is. IP-only on a logged-in endpoint is a hole you gave to whoever changes address.

  • Why does in-memory rate limiting fail on serverless?

    Because the counter dies when the isolate dies, and because three instances are three counters. You have set a number you can show in one preview. You have not set a cap that holds in prod. Use something shared, or use the platform's edge limit and be honest about the coarseness. A middleware Map is fine as a prototype. It is a lie as the sentence you tell leadership.

  • What should a 429 do differently from a 500 or a captcha?

    It should say you said no to frequency, not that the site broke, and not that the user must prove they are human unless the action requires it. Retry-After. A body the client can parse. A UI a human can read. A 500 asks the retry queue for more load. A captcha on the whole site is theatre for a call you would not limit. The error-pages post is chrome. Here it is letting the cap be true.

If you have a WAF on the hostname and a login without a cap

Let us separate the expensive call from 'protect the API'.

A named cap and an honest 429 beat a rule you hung on the wrong door.

Why we wrote this

This is how we think — and it's what we build.

Our insights are about the work we actually do. If this hit something you're working on, there's a concrete service that lines up.