Skip to content

← All writing

For developers

Rate limiting on the wrong axis is worse than none

31 July 2026 · 2 min read · TMailr

Most rate limiting is per IP, because the IP is the thing you have. It is also, frequently, the wrong axis, and a limit on the wrong axis is worse than none: it inconveniences ordinary users and does not stop the abuse it was built for.

Ask what is scarce

The question is not how many requests should one client make. It is what does this cost, and who pays. Answer that and the axis picks itself.

For an endpoint that sends a confirmation email to an address the caller chooses, the scarce thing is the reputation of the address being written to and the patience of the person who owns it. A per-IP limit does nothing to protect them: an attacker with a hundred addresses can hammer one victim from a hundred sources, each politely under the limit, and the victim gets a hundred emails.

Limit per destination and that attack stops, regardless of where it comes from.

Several axes at once

  • Per destination, to protect the person receiving whatever your endpoint sends.
  • Per source, to stop one client monopolising you.
  • Globally, to protect the system from something you have not thought of yet.

They answer different questions and none of them substitutes for another.

The limit must not leak

A per-destination limit can tell an attacker things. If a known address is refused sooner than an unknown one, refusals become a way to enumerate which addresses are already in use. Keep the threshold identical whatever the address, so a refusal reveals only that somebody has been busy.

Two mistakes worth naming

The first is a counter that counts refusals. If the check happens after the increment, every rejected retry pushes the number higher, so a client politely retrying digs itself deeper. Make sure a refusal either does not count or does not extend the window, and be deliberate about which.

The second is the sliding window nobody meant to build. Calling EXPIRE on every increment resets the clock each time, so a client at the limit is locked out forever. Set the expiry when the counter is created and leave it alone. The related failure is the counter that never expires at all, when a process dies between the increment and the expiry, and that source is blocked permanently with nothing in the logs explaining it.

Refuse in a way that helps

A limit that cannot be told apart from a fault will be reported as a fault. Say which limit was hit and roughly when it clears, and distinguish a short burst limit from a daily budget: waiting is the right answer to the first and useless against the second.

We got this wrong on our own side. A test harness died on a bare 403 that read exactly like the API being broken, and the first fix retried, which for a daily counter only pushed the number up without bringing the reset closer. The API had been saying which limit it was all along. Nothing was reading it.

More on for developers