Web Rate Limiting Design
Activate this skill when the user needs to design or debug rate limiting for page traffic on a public site, as opposed to an API: sizing limits from real user percentiles, choosing keys and windows, deciding between in-memory and shared counters, exempting search crawlers, and returning a correct 429. Triggers on "rate limiting," "rate limit middleware," "token bucket," "sliding window," "429 Retry-After," "per-IP limit," "Next.js middleware rate limit," "Sec-Fetch-Dest," "Upstash ratelimit," "Cloudflare rate limiting rule," or "bot traffic keeps reading every page." Covers hourly plus daily buckets, multi-instance effects on the effective limit, framework pitfalls, and testing against a production build before shipping.
You are a site reliability engineer who has run public content sites on pay-per-use hosting for a decade. You have shipped rate limits that stopped a crawler cold and rate limits that stopped nothing because every instance kept its own counter, and you have once locked out an entire university behind one NAT address. You size limits from the measured distribution of real visitors and the measured pattern of the abuser, and you test them against a production build before they meet a real browser.
## Key Points
- **IPv6 /64**: a single subscriber usually has a /64 (or /56); keying on the full address lets a client rotate through 2^64 addresses. Truncate.
- **IPv4 /24**: catches small pools and cloud tenants, and also catches a whole ISP's carrier-grade NAT block. Use only as a second, larger bucket (for example 10× the per-IP limit).
- **Authenticated user**: unlimited or very high for signed-in accounts you can suspend.
1. From request logs, count top-level document navigations per IP per hour and per day over 30 days, excluding known crawlers and your own monitoring.
2. Split by whether the IP had an engaged session (or two or more pages). Take percentiles of the engaged population: `p99` and `p99.9` per hour and per day.
3. Identify your shared-IP outliers: the IPs with the most distinct cookies or User-Agents. Their volume is many humans; your per-IP limit must sit above their daily peak or use cookie keying.
4. Profile the abuser the same way: pages per hour, pages per day, inter-request spacing.
6. Write the four numbers (human p99.9 hour and day, abuser hour and day) next to the limits in the code. The next person will need them.
- Googlebot: published ranges at `developers.google.com/search/apis/ipranges/googlebot.json`, or reverse DNS to `googlebot.com` / `google.com` followed by a forward lookup that returns the same IP.
- bingbot: published ranges at `www.bing.com/toolbox/bingbot.json`, or reverse DNS to `search.msn.com` with forward confirmation.
- Applebot: reverse DNS to `applebot.apple.com` with forward confirmation.
- OpenAI, and other vendors that publish JSON range lists: fetch the list on a schedule, cache it, match by CIDR.
## Quick Example
```bash
for i in $(seq 1 70); do
curl -s -o /dev/null -w '%{http_code} ' -H 'Sec-Fetch-Dest: document' \
-H 'User-Agent: test-limit/1.0' http://localhost:3000/skills/example-page
done; echo
```skilldb get bot-traffic-and-crawler-defense-skills/web-rate-limiting-designFull skill: 168 linesWeb Rate Limiting Design
You are a site reliability engineer who has run public content sites on pay-per-use hosting for a decade. You have shipped rate limits that stopped a crawler cold and rate limits that stopped nothing because every instance kept its own counter, and you have once locked out an entire university behind one NAT address. You size limits from the measured distribution of real visitors and the measured pattern of the abuser, and you test them against a production build before they meet a real browser.
Core Principle: Limit Navigations, Not Requests
An API rate limit counts calls. A page rate limit must count something a person does: navigating to a page. A single page load on a modern site is one HTML request plus twenty asset requests plus, in an app-router framework, prefetches for every link on screen and a server-component fetch for every client-side navigation. Count all of those and a human reading three pages hits an "abusive" number in a minute. Count only top-level document navigations and the numbers become human-sized, comparable across visitors, and comparable against the crawler.
The second principle: the limit has two jobs, and one bucket cannot do both. A burst limit (per hour) stops a fast scraper. A volume limit (per day) stops the slow, steady crawler that reads 500 pages a day at 21 an hour, which is under any hourly cap generous enough for a person. The measured crawler in this pack's background never exceeded 25 pages in an hour and read 500 every day; only the daily bucket caught it.
Algorithms
| Algorithm | Behaviour | Memory per key | Boundary burst | Use it for |
|---|---|---|---|---|
| Fixed window | Counter resets at window edge | 1 integer + window id | Up to 2× limit across the edge | Daily caps where 2× at midnight is acceptable |
| Sliding window log | Timestamp of every hit in the window | O(limit) | None | Exact small limits, low traffic |
| Sliding window counter | Weighted blend of current and previous fixed windows | 2 integers | Near none | Hourly caps; what most libraries call "sliding window" |
| Token bucket | Bucket refills at rate r, holds up to b tokens; each hit takes one | 2 numbers | Controlled: burst b then rate r | Human-friendly shaping (a visitor can open 10 tabs, then settle to a rate) |
| Leaky bucket / GCRA | Equivalent to token bucket expressed as a theoretical arrival time | 1 timestamp | Controlled | Same as token bucket, single value to store |
For page limits on a small site: sliding window counter for the hour, fixed window for the day (a UTC day, or the visitor's day if you key on cookie). Token bucket is better for APIs where the client can be told the refill rate.
Keys
- IP (IPv4): the default. Weak against a scraper with a proxy pool, strong against a single-machine headless crawler, and fair to most humans. Get the client IP from the right hop: on Cloud Run the client address is appended to
X-Forwarded-Forby the load balancer, so take the address that many hops from the right, never the leftmost value, which the client controls. Count your trusted proxies once and put the number in configuration. - IPv6 /64: a single subscriber usually has a /64 (or /56); keying on the full address lets a client rotate through 2^64 addresses. Truncate.
- IPv4 /24: catches small pools and cloud tenants, and also catches a whole ISP's carrier-grade NAT block. Use only as a second, larger bucket (for example 10× the per-IP limit).
- ASN: aggregate by network owner (a MaxMind GeoLite2-ASN lookup, or
ip.src.asnumat Cloudflare). A cap per hosting-provider ASN is a reasonable outer bucket because residential users are not on hosting ASNs. Requires a lookup on every request or at the edge. - Session cookie: the crawler that uses a fresh profile per page never returns a cookie. Treat cookie-less clients as IP-keyed and cookie-bearing clients as cookie-keyed with a higher limit; this makes NAT-shared humans invisible to each other's counts while a stateless crawler stays IP-bound.
- Authenticated user: unlimited or very high for signed-in accounts you can suspend.
Sizing from Measurement
- From request logs, count top-level document navigations per IP per hour and per day over 30 days, excluding known crawlers and your own monitoring.
- Split by whether the IP had an engaged session (or two or more pages). Take percentiles of the engaged population:
p99andp99.9per hour and per day. - Identify your shared-IP outliers: the IPs with the most distinct cookies or User-Agents. Their volume is many humans; your per-IP limit must sit above their daily peak or use cookie keying.
- Profile the abuser the same way: pages per hour, pages per day, inter-request spacing.
- Choose limits above the human
p99.9and below the abuser's daily volume. In the measured case the crawler did ~21 pages per hour and ~500 per day; limits of 60 per hour and 200 per day sat above the observed human distribution on that site (measure yours; do not copy these numbers) and below the crawler's daily volume. The hourly bucket never touched it. The daily bucket cut it to 200 pages a day, 40% of its former volume, with every further navigation that day answered429; whether it then slows down, leaves, or spreads across addresses is what the counted-hit log tells you next. - Write the four numbers (human p99.9 hour and day, abuser hour and day) next to the limits in the code. The next person will need them.
Exempting Verified Crawlers
Search crawlers must not be limited, and a scraper must not be able to claim the exemption. Verify by IP, never by User-Agent alone:
- Googlebot: published ranges at
developers.google.com/search/apis/ipranges/googlebot.json, or reverse DNS togooglebot.com/google.comfollowed by a forward lookup that returns the same IP. - bingbot: published ranges at
www.bing.com/toolbox/bingbot.json, or reverse DNS tosearch.msn.comwith forward confirmation. - Applebot: reverse DNS to
applebot.apple.comwith forward confirmation. - OpenAI, and other vendors that publish JSON range lists: fetch the list on a schedule, cache it, match by CIDR.
Do the verification asynchronously or from a cached table; a DNS lookup inside a request path is a latency and cost problem. Unverified clients claiming a crawler token get the ordinary limit, not a block; they may be a misconfigured legitimate tool.
The 429 Response
- Status
429 Too Many Requests. Retry-After: <seconds>computed from the bucket reset, not a constant.Cache-Control: no-store.- For
Accept: text/html(and every navigation), an honest HTML body: what happened, the reset time, a contact address, and a sentence saying that declared crawlers can be exempted by request. A blank 429 teaches nothing and generates support email. ForAccept: application/json, a small JSON body. - Optionally the IETF draft
RateLimit-PolicyandRateLimitheaders (draft-ietf-httpapi-ratelimit-headers); harmless and useful to good clients. - Log every counted hit and every 429 with IP, key type, User-Agent, path and
Sec-Fetch-Dest. You will need this to answer "did the limit hit a human".
Counters: In-Memory Versus Shared
An in-memory Map in the server process is free, fast and correct for exactly one instance. On a platform that scales horizontally:
- Each instance keeps its own count, so the effective limit is
limit × instancesif the load balancer spreads a client evenly, and somewhere betweenlimitandlimit × instancesotherwise. - Instances scale to zero and lose the count; a daily bucket evaporates at the first idle period.
- Cloud Run session affinity reduces the spread but is best-effort.
For a small site that usually runs one instance, in-memory counting catches the single-machine crawler because that crawler hits the same instance; document the caveat in the code. When you need correctness, use a shared store and price it per hit:
| Store | Cost model | Notes |
|---|---|---|
| Redis (Upstash, Memorystore, self-hosted) | Upstash bills per command with a free daily allowance; a @upstash/ratelimit sliding window costs one or two commands per hit | The standard answer for serverless; sub-millisecond from the same region |
| Firestore | One read and one write per hit; daily free quota then per-100k pricing | Adds two round trips to every navigation and a sustained-write limit of about one write per second per document, which a burst breaks. Poor fit for hot keys |
| Edge KV (Cloudflare KV, Vercel Edge Config) | Eventually consistent | Fine for allow-lists and block-lists, wrong for counters |
| Durable Objects / Cloudflare Rate Limiting rules | Counted at the edge before the origin | Removes the origin from the loop entirely; see below |
Whatever the store, keep the check off the asset path: match only HTML navigations so the twenty asset requests per page never touch the counter.
Framework Pitfalls
Next.js (App Router). Client-side navigations and prefetches are fetch() calls carrying RSC: 1, and prefetches carry Next-Router-Prefetch: 1. Next.js strips those internal headers before your middleware runs (in Next.js 16 the file is proxy.ts; the behaviour is the same), so middleware cannot distinguish a prefetch from a navigation by those headers. Key on the browser-owned fetch metadata instead: Sec-Fetch-Dest: document is a top-level navigation; empty is a fetch() (RSC payload, prefetch, beacon); image, script, style, font are assets. Browsers set these and page JavaScript cannot override them. Non-browser clients send no Sec-Fetch-* headers at all; count those as documents, because they are not prefetching anything.
// proxy.ts (Next.js 16) or middleware.ts — count only real page navigations
import { NextResponse, type NextRequest } from 'next/server';
export const config = { matcher: ['/skills/:path*'] };
export function proxy(req: NextRequest) { // export `middleware` instead on Next.js 15 and earlier
const dest = req.headers.get('sec-fetch-dest');
if (dest && dest !== 'document') return NextResponse.next(); // prefetch, RSC, asset
const ip = clientIp(req.headers.get('x-forwarded-for'), TRUSTED_HOPS);
if (isVerifiedCrawler(ip)) return NextResponse.next();
const ua = req.headers.get('user-agent') ?? '';
const hour = bump(`h:${ip}`, 3600), day = bump(`d:${ip}`, 86400);
log({ ip, ua, path: new URL(req.url).pathname, dest, hour, day });
if (hour > 60 || day > 200) {
return new NextResponse(html429, {
status: 429,
headers: { 'Retry-After': String(retryAfter(hour > 60 ? 'h' : 'd')), 'Cache-Control': 'no-store',
'Content-Type': 'text/html; charset=utf-8' },
});
}
return NextResponse.next();
}
Also: the middleware matcher must exclude /_next/static, /_next/image, fonts and images, and any route behind ISR that a CDN serves without hitting you. Static prerendered pages still pass through middleware on Vercel and on a self-hosted Node server, so counting there is correct; on a pure CDN in front, counting has to move to the edge.
Edge and CDN products. Cloudflare Rate Limiting rules are available on every plan with plan-dependent limits on rule count, counting period and characteristics (Free is a single rule keyed on IP over a short period; higher plans add longer periods and keys such as headers, cookies and ASN; check the current plan table). Vercel Firewall custom rules support a rate-limit action on paid plans, keyed on IP or JA4 among others. Google Cloud Armor supports throttle and rate_based_ban rules, keyed on IP, forwarded IP, header, cookie, path or region, but only behind a Global external Application Load Balancer, which has its own monthly cost. Whichever you use, the edge rule and the origin rule should agree on what a "navigation" is; at the edge you typically match on path plus Accept containing text/html plus method GET.
Procedure: Testing Before Shipping
next build && next start(or your framework's production mode). Development servers add requests and disable prefetching; the numbers are wrong there.- Open the site in a real browser with the network panel open. Scroll a listing page so that 30 links prefetch. Confirm the counter did not move (read your log line).
- Navigate through 10 pages by clicking. Confirm exactly 10 counted hits, all
dest=document. - Hammer it from a shell and watch for the transition:
for i in $(seq 1 70); do
curl -s -o /dev/null -w '%{http_code} ' -H 'Sec-Fetch-Dest: document' \
-H 'User-Agent: test-limit/1.0' http://localhost:3000/skills/example-page
done; echo
- Confirm the 61st response is
429, carriesRetry-After, and has an HTML body. - Confirm the next request from a different source IP (or a different
X-Forwarded-Forat the correct hop in a trusted test setup) is not affected. - Confirm Googlebot-verified ranges bypass by feeding a known Googlebot IP through the same path in a test harness.
- Deploy behind a feature flag or at 10× the intended limit for a day, read the counted-hit log, then tighten.
Checklist
- Only top-level document navigations are counted; prefetches, RSC fetches and assets are not.
- Client IP is taken from the correct
X-Forwarded-Forhop. - Hourly and daily buckets, sized from the human
p99.9and the abuser's measured volume, with the numbers recorded in the code. - Verified search crawlers exempt by IP; unverified claims get the normal limit.
-
429hasRetry-After,no-store, and an HTML body with contact details. - Every counted hit is logged with IP, User-Agent, path and
Sec-Fetch-Dest. - Multi-instance behaviour documented; shared store priced per hit if used.
- Tested against a production build, in a browser and from a shell, before release.
Common Mistakes
- Counting every request. The human reading three pages trips the limit first.
- Trusting framework-internal headers that the framework strips before your code runs.
- Only an hourly limit, which a slow crawler never reaches.
- Reading the leftmost
X-Forwarded-Forvalue. - Blocking on a User-Agent that says "Googlebot".
- Using Firestore as a hot-key counter and paying two operations for every page view.
- Shipping a
429with no body and noRetry-After, then discovering it from a customer. - Testing on the development server, where prefetch behaviour differs.
Limits
Rate limiting is proportional pressure, not identification. A crawler that spreads across a proxy pool slips under per-IP limits and needs ASN or fingerprint keys at the edge, or content gating. A limit tight enough to stop everything automated will stop humans behind shared addresses first. When the measured cost of the crawler is trivial and the harm is analytics contamination, the cheaper fix may be a delayed beacon and engaged-session reporting rather than a limit at all; this pack's cost and analytics skills make that call.
Install this skill directly: skilldb add bot-traffic-and-crawler-defense-skills
Related Skills
Access Log Forensics for Bots
Triggers when the user needs to find, profile or verify an automated client in access logs: grouping by IP, /24, ASN and User-Agent, reading Sec-Fetch headers, checking TLS fingerprints, confirming a claimed Googlebot, or reconstructing a crawler's sessions. Trigger on "access logs," "who is this IP," "crawler in the logs," "verify Googlebot," "reverse DNS," "JA3," "JA4," "Sec-Fetch-Dest," "Logs Explorer query," "BigQuery logs," "CloudFront logs," "scraper IP," or "bot traffic analysis." Covers Cloud Logging, Vercel, nginx, Caddy and CloudFront formats, jq, awk and SQL snippets, RDAP and whois for ownership, and the sampling and cost of logging itself.
Bot Traffic Signatures in Analytics
Triggers when the user suspects their analytics are inflated by automation, or asks why GA4, Plausible, Fathom or their own server-side counters show users, pageviews or a "most viewed" list that does not match reality. Trigger on "bot traffic," "GA4 users vs engaged sessions," "direct traffic spike," "crawler in analytics," "headless browser," "fake pageviews," "engagement rate," "IAB bot filter," "Plausible bots," or "most viewed is wrong." Covers the one-page/under-ten-seconds/new-user-every-hit signature, hourly steadiness, single OS-browser-device fingerprints, uniform per-page counts, GA4 comparisons, data filters and Data API queries, why IAB filtering misses JavaScript-executing headless browsers, and what to report instead.
Building a Polite Crawler
Activate this skill when the user is writing a crawler, scraper or fetcher of any size and wants it to be identifiable, cheap for the sites it visits, resilient to rate limiting, and defensible. Triggers on "build a crawler," "web scraping etiquette," "polite scraper," "obey robots.txt," "Crawl-delay," "conditional requests," "ETag If-Modified-Since," "back off on 429," "headless browser scraping," "scraper legal," "CFAA hiQ," "GDPR scraping," or "terms of service scraping." Covers self-identification, robots.txt parsing, rate and concurrency limits with jitter, caching and revisit policy, sitemaps before spidering, Retry-After handling, preferring HTML or APIs over headless browsers, and an outline of the legal landscape with an explicit not-legal-advice line naming the professional to consult.
Content Exposure and Preview Gating
Triggers when the user wants to know exactly what their site hands to any client that asks, or wants to gate full text behind a preview without destroying search visibility. Trigger on "content exposure," "web scraping protection," "preview gating," "show 30%," "paywall structured data," "RSC payload," "__NEXT_DATA__," "sitemap leaks," "public JSON," "canary text," "watermark content," "scraper copied my site," or "API keys and quotas." Covers every path full text ships on (server-rendered HTML, React Server Component payloads, prerendered routes, JSON data files, sitemaps, public APIs, RSS), why client-side gates are theatre, server-side preview gating with indexable summaries, API-first access, canary sentences, and the SEO and AI-search cost of hiding text.
Crawler Cost Accounting
Activate this skill when the user wants to know what automated traffic actually costs them on pay-per-use hosting, or is deciding whether a crawler is a billing problem, a data problem, or no problem at all. Triggers on "cloud bill," "bot traffic," "crawler cost," "Cloud Run pricing," "Vercel usage," "Lambda invocations," "egress," "free tier," "web scraping cost," or "how much is this scraper costing me." Covers per-request, CPU-time, memory, egress, image optimisation and database charges triggered per page, where free tiers end, a fully worked arithmetic example, and how to measure from billing exports and request logs.
Edge Bot Management and WAF
Activate this skill when the user is choosing or configuring an edge product to handle bot traffic: Cloudflare Bot Fight Mode, Super Bot Fight Mode, Bot Management, Turnstile or managed challenges; Google Cloud Armor; AWS WAF Bot Control; Vercel Firewall and Attack Challenge Mode; or Fastly. Triggers on "WAF," "bot management," "Cloudflare challenge," "Turnstile," "Cloud Armor pricing," "AWS WAF Bot Control," "Vercel Firewall," "Attack Challenge Mode," "block scraper at the edge," or "is this worth $5 a month." Covers what each product actually does and charges for, what JavaScript challenges do to search crawlers, AI crawlers, link previews and accessibility, when a control costs more than the crawler, and a rule order that allow-lists verified crawlers first, challenges by score, and blocks only on evidence.