September 25, 2026 / 5 min read
Why our contact form rate-limits in memory
The contact form on this site runs on a single Node.js instance. It has no Redis, no WAF rules, no queue. It gets spam like every form on the public web, and it stops the worst of it with two mechanisms that cost nothing: an in-memory token bucket and a honeypot. This site is our portfolio, so here is exactly how that works.
The threat model, honestly
A contact form behind a server action has exactly one expensive outcome: an outbound email to our inbox and, in the worst case, a bounce against our sender reputation. We are not protecting a database of millions of rows. We are keeping our inbox readable and our delivery rate healthy. That framing matters, because it tells you how much complexity is justified: almost none.
A token bucket with two rules
Each submitting IP gets a bucket. Within a ten-minute window, an IP may send at most five messages, and never twice within thirty seconds:
const WINDOW_MS = 10 * 60 * 1000;
const MAX_PER_WINDOW = 5;
const MIN_SPACING_MS = 30_000;
function rateLimit(ip: string): boolean {
const now = Date.now();
const bucket = buckets.get(ip) ?? { timestamps: [], last: 0 };
bucket.timestamps = bucket.timestamps.filter((t) => now - t < WINDOW_MS);
if (bucket.timestamps.length >= MAX_PER_WINDOW) return false;
if (bucket.last && now - bucket.last < MIN_SPACING_MS) return false;
bucket.timestamps.push(now);
bucket.last = now;
buckets.set(ip, bucket);
return true;
}
The spacing rule does most of the work against scripts that fire bursts. The window cap catches slower, distributed retries. The map is bounded, so memory stays flat under address-spoofing attempts.
Yes, this state dies on restart, and yes, a botnet rotating IPs walks through it. For a corporate contact form, both of those are acceptable residuals. The honeypot catches the remainder of the naive scripts.
The honeypot, and one rule about lying
The form contains a hidden website field that humans never see. Anything
that fills it is a bot, and the server action returns a success response
without sending mail. That last detail is deliberate: an explicit rejection
teaches the bot author which signal to remove. A silent fake success keeps
them guessing forever.
The same principle applies to rate limiting. The error message says "too many messages, try again in a few minutes" regardless of which rule fired, because the exact threshold is the only thing an attacker learns from a precise error.
When this stops being enough
The day we run multiple instances behind a load balancer, in-memory state stops being shared and the limits halve their effectiveness. That is the moment to move the bucket into the shared store we will by then need anyway. Until then, adding Redis to a form that receives a few messages a day would be infrastructure theater, and we would rather ship the version we can fully explain.