Every Next.js contact-form tutorial includes rate limiting. Most recommend Upstash Redis with a sliding window, 3 submissions per minute per IP. I shipped that pattern, ran it for a month, and then deleted it.
This is a companion piece to the four-line spam filter that replaced reCAPTCHA on this site. Same contact form. One more simplification.
What I had
The canonical "secure contact form" stack:
- Honeypot + time-floor + size limits (the post linked above)
- Upstash Redis rate limiter, 3/min per IP
- Cloudflare in front of everything
- Telegram notification on valid submission
Roughly 80 lines of code plus a Redis dependency plus a Cloudflare worker. For a contact form that gets ~5 submissions a week.
What I noticed after a month
Three data points made me reconsider.
1. The rate limiter never triggered on a real attack.
The honeypot and time-floor caught every spam attempt I could verify. I grepped logs for "rate limit exceeded" events — zero, in 31 days. The bots that tried to submit were all failing the honeypot or the time-floor before hitting the rate limit. The rate limiter was downstream of the filters that actually worked.
2. It did trigger on legitimate users twice.
Both times were humans on corporate networks — large office, shared IP, one person submitting for themselves, another person submitting right after. Second submission hit the 3/min limit. They got a polite "please try again later" response and, probably, didn't.
Two lost leads on a form that sees ~20 submissions a month is a 10% false-positive rate on the thing that's supposed to catch bots. That's worse than not rate limiting.
3. Redis added a failure mode.
Upstash is excellent. But it's another dependency, another API call per submission, another place that can be slow or unavailable. Twice during the month the Upstash response was >500ms, which made the submission feel sluggish. One time (briefly) Upstash returned an error, which my code handled by failing open — the rate limit didn't apply that minute. At which point: what was it really defending?
The math on when rate limiting pays off
Rate limiting is genuinely valuable when:
- Your form endpoint does expensive work. Sending an email is cheap (Resend handles it). Running an ML model isn't. If each submission costs 50ms of CPU or a €0.002 API call, rate limit.
- You're being targeted by a specific attacker. A single IP hammering you 500 times in a minute is exactly what rate limiters are for. Cloudflare usually catches this at the edge, but application-level rate limiting is belt-and-suspenders.
- Your endpoint creates user-visible state. A signup form that creates accounts, or a comment form that posts publicly, benefits from rate limiting as abuse protection — because duplicates aren't just annoying, they're public.
- Compliance or audit requires it. Some enterprise customers will ask. That's a real reason even if the math doesn't otherwise support it.
Rate limiting is a reflex-without-payoff when:
- The form is rate-limited upstream. Cloudflare at 100 req/min per IP catches 99% of abuse before your code sees it. Adding a second layer at 3/min per IP is usually friction without marginal defense.
- The endpoint is already filtered by cheap content checks. My honeypot + time-floor rejects bots in <1ms with no downstream cost. Rate limiting is downstream of the filters that actually matter.
- The volume is low. A portfolio contact form at 5 submissions a week is not going to be DDoS'd by a motivated adversary into me paying a Stripe-level bill. The worst case — a few hundred spam submissions in a night — is an inconvenience, not an incident.
What I replaced it with
Nothing. The filter chain is now:
- Cloudflare at the edge (set-and-forget)
- Honeypot
- Time-floor (1500ms minimum)
- Size limits (
name < 100,email < 200,message < 5000) - Telegram notification
That's the whole pipeline. No Redis, no state, no sliding window. 60ms p95 response time.
The worst case I might be wrong about
A motivated attacker could, in theory, script a headless browser that mounts the page, waits 1600ms, fills the form, and submits — bypassing the time-floor. Now I have a bot that submits 100 times a minute.
What actually happens then:
- Telegram starts buzzing. I see it.
- I add rate limiting in 15 minutes. The code is still in git history; I just
git revertthe deletion. - Or I ban the IP range in Cloudflare.
The key insight: rate limiting is a fix for a specific attack shape. I can add it back in under an hour when I see the attack. Paying for it preemptively, forever, on a form that has never needed it, is a tax against a threat that hasn't materialized.
The generalized rule
I used to add every security middleware from the top tutorial. Now I ask three questions before adding one:
- What specific attack does this defend against? If I can't name it in one sentence, I don't need the defense.
- What does it cost — in code complexity, in dependencies, in latency, in false positives? Each defense is a tax.
- Can I add it later when the attack actually appears? For most defenses on small-traffic systems, the answer is yes — and preemptive defense is overspending.
Rate limiting on a contact form failed all three for me:
- The attack it defends against is mostly caught by cheaper, upstream filters.
- It added Redis, ~20 lines of code, and 2 false positives per 20 submissions.
- I could absolutely add it back in an hour when (if) an actual attacker shows up.
Gone. Not missed.
The caveat
This works because the form is mine, the volume is small, and the downstream action (Telegram notification to me) has no public side-effects. If you're building:
- A high-traffic public API
- A signup form that creates user accounts
- A comment form that posts publicly
- A payment-adjacent endpoint
- An SMS-triggering form (real cost per submission)
— keep your rate limiter. It exists for a reason, and the reason is your specific shape.
This post is specifically about low-traffic, owner-viewed, side-effect-free form endpoints. The internet mostly writes defensive-by-default guides, which is right for the median project and wrong for specific ones. Knowing when "specific" applies to your project is how you stop paying for defenses you don't need.
The honest trade I'm making
I'm trading a defense-in-depth against an attack that hasn't happened, for a simpler codebase and fewer dependencies. That trade is right for this form, today. It would be wrong for a different form tomorrow.
The failure mode, if I'm wrong: a weekend of spam in Telegram and fifteen minutes of code revert. I can live with that.
The failure mode if I'd kept the rate limiter: two lost real conversations per month, forever, silently. I can't.
If you're reviewing your own security stack and want a second opinion on what to keep versus delete, I'll read through your threat model and respond with one-page of honest feedback. Not trying to sell an audit — half the time my answer is "you're overspending." Contact form.