Skip to content
SheetLink Forms

Spam defense · 2026-08-01 · 8 min read · By Arden Talbot, founder of SheetLink

Rate limiting forms: a practical guide

Windows, keys, burst versus sustained, the CGNAT trap, and what a 429 should look like to a real person. The least glamorous spam layer, done properly.

A ledger-styled illustration of submission rows flowing through a narrow gate with a counter dial, a burst of rows queuing behind it in orderly columns.

The unglamorous layer

Nobody demos rate limiting. It has no widget, no dashboard moment, no vendor keynote. It is also the layer standing between a normal Tuesday and the morning you open your spreadsheet to forty thousand identical rows, because somewhere a script found your endpoint and ran all night.

Every public form endpoint needs it, most people never think about it until the bad night happens, and the design space - what to count, over what window, keyed by what - is full of quiet traps that turn a protective limit into a machine for blocking real customers. This guide walks the practical decisions in order, including the one failure mode that catches nearly everyone: the shared IP address.

What rate limiting is for - and not for

Rate limiting answers exactly one question: is this sender submitting too often? It reads no content and judges no intent. One carefully crafted spam message sails through any rate limit, because one of anything is under every threshold. If you expect a rate limit to catch spam, you will be disappointed in both directions.

What it uniquely handles is volume: runaway scripts, retry loops gone wrong, bulk spraying, and the cost side of abuse - because every accepted submission consumes downstream work like heuristic checks, storage, and delivery to your sheet. Content layers decide what gets through; the rate limit bounds how much can arrive at all. Both jobs are necessary, and neither can do the other's.

Windows: fixed, sliding, token bucket

Three standard shapes. A fixed window counts submissions per calendar bucket - per minute, per hour - and resets at the boundary. Simple, but gameable: ten requests at 11:59:59 and ten more at 12:00:01 is twenty in two seconds, all legal. A sliding window counts over the trailing interval from right now, closing that boundary trick at slightly more bookkeeping cost.

A token bucket grants each sender a budget that refills at a steady rate and can accumulate up to a cap - which is just a precise way of saying "allow short bursts, bound the long-run average." For form traffic, which is naturally bursty and low-volume per person, bucket-style or sliding-window limits fit human behavior much better than hard fixed windows do.

Burst versus sustained: you want both

A single threshold cannot describe abuse, because abuse comes in two tempos. The machine-gun burst - dozens of submissions in a few seconds - is caught by a short-window limit. The slow drip - one submission every ninety seconds, all day, forever - stays politely under any per-minute cap and still buries you by dinner. Catching it takes a second, longer-horizon limit: per hour or per day.

So a practical policy is a pair: a tight burst limit that no human typist will ever brush against, and a sustained ceiling that no legitimate individual approaches but any drip campaign hits by mid-morning. The numbers themselves matter less than having both tempos covered; either limit alone leaves the other attack open.

Keying by IP: the obvious default

A limit needs an identity to count against, and the source IP address is the default for good reason: it requires no cooperation from the client, cannot be cleared like a cookie, and is available on every request. For bounding what any single machine can do to your endpoint, per-IP limits are the workhorse.

Two weaknesses, in opposite directions. Attackers with a botnet or rotating proxies spread requests across hundreds of addresses, each individually under the limit - so per-IP alone cannot cap an attack's total size. And in the other direction, an IP address is not a person. Sometimes it is a thousand people, which is where the trap lies.

The CGNAT problem: when one IP is a whole neighborhood

Carrier-grade NAT means an ISP parks many customers behind one public IPv4 address - the practice is common enough that RFC 6598 reserved a dedicated 100.64.0.0/10 block for it. Mobile networks lean on it heavily: phones across an entire region can egress from a small pool of addresses. Add office NATs, university networks, and conference wifi, and "one IP" routinely means hundreds of simultaneous humans.

Now replay a tight per-IP rule against that reality: a form limited to a few submissions per hour per IP breaks the moment your link circulates in an office, a lecture hall, or any audience on mobile data. The victims look like an attack and are actually your best traffic. The lesson: per-IP limits must be generous enough to absorb shared-address bursts, and something other than the IP has to carry the fine-grained work.

Better keys, and the composite answer

The complement is keying by the form itself. A per-form ceiling bounds total exposure no matter how many IPs an attacker spreads across - a botnet defeats per-IP counting but cannot defeat the sum. Cookie- or storage-based sender keys exist too, but anything a client can clear, a script can clear faster; treat those as hints, not enforcement.

The practical composite is the pair: per-IP limits, tuned loose enough for CGNAT reality, to stop any single machine from monopolizing the endpoint; per-form limits to cap what an entire campaign can inflict. That combination is what SheetLink Forms runs on every endpoint by default - it is part of the standard stack described in how it works, alongside the honeypot and content heuristics that judge the traffic the limits let through.

What a 429 should look like to a real person

When a limit trips, the response is part of the design. The correct status is 429 Too Many Requests, defined in RFC 6585, ideally with a Retry-After header saying when to try again. But the status code is for machines; the experience is for the person who just hit submit.

For a plain HTML post, that means a readable page - "too many submissions from your network, please try again in a minute" - never a blank error or a hang. For AJAX submissions, a structured error the front end can surface inline without wiping what the visitor typed. A rate-limited human should understand what happened, keep their words, and succeed on retry. Silence here is the transport-layer version of the silent drop, and it costs leads the same way.

Tune against real traffic, not folklore

There is no universally correct number, because legitimate traffic patterns differ by an order of magnitude between a personal contact form and an event registration form the morning tickets open. Copying limits from a blog post - including this one - substitutes someone else's traffic for yours.

The sane procedure: watch your actual per-form volume for a while, set the burst limit well above any human pattern you observe, set the sustained ceiling above your busiest legitimate day, then check the delivery log for who is actually hitting 429s. If real users appear there, loosen; if drip campaigns are living under your ceiling, tighten. Limits are a dial you revisit, not a constant you enshrine.

Where it sits in the stack

One distinction ties this guide to the rest of the series. Rate limiting rejects, retryably: a 429 tells the sender the door is briefly closed, and a legitimate sender simply tries again. Content signals quarantine: an accepted submission the stack distrusts is held for human review rather than refused. Transport problems get transport answers; judgment problems get judgment answers - collapsing the two produces either silent loss or unusable queues.

So the full arrangement reads: rate limits bound volume, the honeypot and timing signal shed naive bots, heuristics flag suspicious content into quarantine, and optional Turnstile gates targeted campaigns. The whole stack ships assembled on every endpoint - see the spam protection guide for the complete tour, or the docs for specifics.

FAQ

What limits should I start with on a typical contact form?

Start from your traffic, not a magic number: a burst limit no human typist could hit, a sustained daily ceiling above your busiest legitimate day, both per form. Then adjust from evidence in the delivery log rather than from folklore.

Does rate limiting stop distributed botnets?

Per-IP limits do not - a thousand addresses sending once each never trip an IP counter. That is what per-form ceilings are for, and why the volume layer is paired with content layers like the honeypot and heuristics that judge each submission individually.

Should a rate-limited submission be quarantined instead of rejected?

No. Rate limiting is a transport decision: the 429 tells a legitimate sender to retry in a moment, so nothing is lost. Quarantine exists for accepted submissions whose content is in doubt - a different question deserving a different mechanism.

What about offices, schools, and mobile users sharing one IP?

That is the CGNAT problem: RFC 6598-style address sharing means one IP can be hundreds of people. Per-IP limits must stay generous enough to absorb that, with per-form ceilings carrying the real enforcement.

My form sits behind a CDN or proxy - which IP gets limited?

Whatever the endpoint sees, which after a proxy is the proxy unless forwarded headers are handled - and X-Forwarded-For must only be trusted from infrastructure you control, since clients can forge it. A hosted endpoint handles this resolution for you; details in the docs.

Do I need rate limiting on a small, low-traffic site?

Yes, precisely because nobody is watching. A runaway script does not check your traffic stats before running all night, and on a quiet site the flood goes unnoticed longest. The limit costs nothing until the night it is the only thing that mattered.

What exactly is HTTP 429?

The standard "Too Many Requests" status from RFC 6585, optionally carrying a Retry-After header. It signals a temporary refusal, not a verdict - the sender is expected to succeed on retry. More in the rate limiting glossary entry.

How does SheetLink Forms rate limit my endpoints?

Every endpoint carries per-form and per-IP limits out of the box, tuned to absorb shared-IP bursts, with over-limit requests receiving a clean 429 rather than a silent failure. It is one layer of the standard stack described on how it works.

Volume control, wired in from day one

Per-form and per-IP rate limits on every endpoint, plus honeypot, heuristics, and quarantine review - free to start.

Start freeSee the live demo

Honeypots still workCAPTCHA fatigue, and what users actually do