Rate Limiting Edge Functions

A static site's pages cannot be overloaded in any meaningful sense — they are files on a CDN. Its handful of dynamic endpoints can. The contact form posts to a function that sends email; the newsletter signup calls an email provider's API; a feedback button writes to a database; a search endpoint proxies a paid API. Within weeks of launch, bots find every one of them. On the site measured here, the contact form received 4,200 submissions in one night from a single botnet, the newsletter provider's API quota ran out, and the function bill for the month tripled.

This guide adds rate limiting to the dynamic endpoints of a static site on Cloudflare: a WAF rate limiting rule in front of all of them, per-endpoint limits inside the Workers using the rate limiting binding, sensible keys, honest 429 responses, and monitoring. The same layering applies on Netlify and Vercel with their own primitives. It is part of Serverless Functions for Static Sites.

Prerequisites

  • A static site with a few edge function endpoints, for example /api/contact, /api/subscribe, /api/feedback, /api/search.
  • A Cloudflare zone (for WAF rules) and Workers with the rate limiting binding available.
  • Logs or analytics showing request volume per endpoint.

Step 1: Know the Normal Traffic

Limits should be generous for real readers and tight for abuse, which requires knowing what normal looks like. Four weeks of logs, per client IP per endpoint:

EndpointReal use per IP (p99)Abusive peaks per IP
/api/contact2 per hour400+ per minute
/api/subscribe1 per hour90 per minute
/api/feedback8 per hour (one per page read)300 per minute
/api/search40 per minute (typing)2,000 per minute

The gap between real use and abuse is two orders of magnitude on every endpoint, which leaves plenty of room for limits that never touch real readers.

Normal and abusive request rates per endpoint Horizontal bars on a shared scale of requests per IP per hour. Real contact form use peaks at 2, abusive peaks exceed 24,000. Real newsletter signups peak at 1, abusive peaks 5,400. Real feedback use peaks at 8, abusive peaks 18,000. Real search use peaks at 2,400, abusive peaks 120,000. Requests per IP per hour, real readers versus bots Search abuse peak 120,000 / h Contact form abuse peak 24,000 / h Feedback abuse peak 18,000 / h Real search use (p99) 2,400 / h Real contact use (p99) 2 / h
Real use is the 99th percentile per IP over four weeks; abusive peaks are the highest observed rates, converted to per hour.

Step 2: A Platform Rule in Front of Everything

A Cloudflare WAF rate limiting rule stops floods before any Worker runs, so abusive requests cost nothing:

Rule: "API flood protection"
If: (http.request.uri.path wildcard "/api/*")
Rate: 120 requests per 1 minute, counting by: IP address
Then: Block for 10 minutes (response 429)

120 per minute is far above any real reader's use of every endpoint combined, and far below the flood rates. The rule is the backstop; per-endpoint limits come next. On Netlify, the equivalent is a rate limit rule in the site's firewall traffic rules; on Vercel, a WAF custom rule with a rate limit action.

Step 3: Per-Endpoint Limits in the Worker

Inside each function, the Workers rate limiting binding applies a limit per key with very low latency:

# wrangler.toml
[[unsafe.bindings]]
name = "CONTACT_LIMIT"
type = "ratelimit"
namespace_id = "1001"
simple = { limit = 5, period = 60 }

[[unsafe.bindings]]
name = "SUBSCRIBE_LIMIT"
type = "ratelimit"
namespace_id = "1002"
simple = { limit = 3, period = 60 }
// src/contact.js
export async function handleContact(request, env) {
  const ip = request.headers.get('CF-Connecting-IP') ?? 'unknown';
  const { success } = await env.CONTACT_LIMIT.limit({ key: `contact:${ip}` });
  if (!success) {
    return Response.json({ error: 'Too many messages from your network. Please try again in a minute.' },
      { status: 429, headers: { 'Retry-After': '60' } });
  }
  // … validate, check Turnstile token, send email
}

The binding counts per location and is designed for abuse prevention rather than exact accounting, which suits this purpose well. For limits that must be exact across all locations — such as a hard daily cap on paid API calls — a Durable Object counter is the right tool.

Layered protection for a contact form endpoint Four layers. A WAF rate limiting rule blocks any IP exceeding 120 API requests per minute before code runs. The Worker's rate limiting binding allows 5 contact submissions per IP per minute. A Turnstile challenge token must validate. A per-email limit of 3 per hour stops repeated submissions from one address across IPs. Only then is email sent. Three layers between a bot and an email inbox WAF rate rule 120 / min per IP on /api/* no code runs Worker rate binding 5 / min per IP on /api/contact 429 with Retry-After Turnstile check token must validate stops scripted posts Per-email limit 3 / hour per address stops rotating IPs Send email provider API real messages only
Most abusive traffic stops at the first layer and costs nothing; each later layer handles what gets through.

Step 4: Choose Keys Carefully

IP addresses are an imperfect key. A company office, a university or a mobile carrier can put thousands of readers behind one address, and IPv6 gives each attacker many addresses. Three adjustments:

  • Generous IP limits, strict identity limits. The IP limit on /api/subscribe is 3 per minute; the per-email limit is 3 per hour, which stops repeated signups of the same address across rotating IPs.
  • Aggregate IPv6. Key IPv6 clients by their /64 prefix rather than the full address.
  • Combine with a challenge. Cloudflare Turnstile on the forms, validated in the function as in Adding a Contact Form with Cloudflare Workers, stops most scripted submissions before rate limits matter.

Step 5: Tell Readers What Happened

A rate-limited form submission must say so. The form's client script handles a 429 by showing the message from the response and disabling the submit button for the Retry-After period, rather than showing a generic error or, worse, a success message. Search, which can legitimately hit its limit when someone types very fast, backs off silently and retries after the window, since the reader is still typing.

Step 6: Watch the Limits

Rate limits need monitoring in both directions: are they stopping abuse, and are they hitting real readers? A dashboard shows 429s per endpoint per hour, the share of blocked requests that carried a valid Turnstile token (real readers pass Turnstile; a high share means a limit is too tight), and the WAF rule's block count. In the first month, 0.3% of blocked search requests had come from sessions that also produced normal page views — a sign of fast typists behind a shared NAT — so the search limit was raised from 30 to 40 per minute.

Daily contact form emails before and after rate limiting Vertical bars of contact form emails sent per day. Before protection, an average of 610 with spikes above 4,000 during attacks. After the WAF rule only, 95. After the Worker limit and Turnstile, 14. After adding the per-email limit, 12, matching the number of genuine messages. Contact form submissions reaching email per day 610 No protection 95 WAF rule 14 + Worker limit + Turnstile 12 + per-email limit
Daily averages over two weeks per stage; genuine messages were identified by manual review of a sample.

Testing the Limits

Rate limits are easy to get wrong in ways that only show up under load, so they are tested before they matter. A small k6 script, run against a preview deployment, sends bursts at each endpoint from one client and checks that the expected number of requests succeed before 429s start, that Retry-After is present, and that a second client in the same window is unaffected. A separate test submits the contact form with a valid Turnstile test token from the same IP several times in a row, mimicking a reader who corrects and resends a message, to confirm that realistic use never trips the limit. The tests run on pull requests that touch the functions or the WAF configuration, which is managed in Terraform alongside the zone, so a change to a limit is always reviewed and tested together with the code that depends on it.

Measured Impact

MeasureBeforeAfter
Contact form emails per day~610 (mostly spam)~12 (genuine)
Newsletter provider API quota exhausted3 times in a month0
Worker invocations per month on /api/*9.4 million1.1 million
Real readers receiving a 429 (sessions with page views)—< 0.05%
Added latency per request (rate limit binding)—< 1 ms

Pitfalls & Rollback

  • Only limiting inside the function. Floods still invoke code and cost money; add a platform rule in front.
  • IP-only limits that are too tight. Shared networks trip them; keep IP limits generous and add identity limits.
  • Silent drops. Readers think their message was sent; always return 429 with a clear message.
  • Exact accounting with an approximate limiter. Use a Durable Object for hard caps on paid APIs.
  • Rollback: WAF rules and bindings can be disabled independently; removing the limit call from a function restores unlimited behaviour immediately.

Conclusion

A static site's dynamic endpoints are few, small and easy for bots to find. Protect them in layers: a platform rate limiting rule that stops floods before code runs, per-endpoint limits inside each function with keys chosen for shared networks, a challenge on forms, and honest 429 responses. On this site that cut spam emails by 98% and function invocations by 88%, while real readers almost never noticed the limits existed.

FAQ

Why do static sites need rate limiting?

The pages are static, but most sites have a few dynamic endpoints such as a contact form, newsletter signup, search API or feedback button. Bots find them quickly, and without limits they can flood inboxes, exhaust third-party API quotas and run up function invocation costs.

Should rate limiting be done in the function or at the platform?

Both layers help. Platform rules, such as Cloudflare WAF rate limiting rules, stop floods before they invoke code. Limits inside the function, using a rate limiting binding or a counter store, handle finer-grained keys such as per email address or per form.

What should the rate limit key be?

Usually the client IP address combined with the endpoint, sometimes with an additional key such as the submitted email for signups. IP-only keys can penalise many readers behind one corporate NAT, so limits should be generous enough for that, with stricter per-identity limits inside.

What should a rate-limited request receive?

A 429 status with a Retry-After header and, for forms, a friendly message explaining that too many attempts were made and when to try again. Never silently drop requests, because real readers then think their submission succeeded.

Filed underHow-tos