Scrape High-Volume Public Data Without Getting Rate-Limited
Naive scrapers hit 429s before proxies even matter. Learn concurrency models, exponential backoff, proxy rotation, and session management to scrape at scale without bans.
Table of Contents
- What Actually Triggers Rate Limiting When Scraping at Scale?
- How Do You Choose the Right Concurrency Model for High-Volume Scraping?
- How Do You Implement Exponential Backoff and Retry Logic?
- How Do You Use Proxy Rotation to Spread Request Load?
- How Do You Set Session State to Avoid Behavioral Fingerprinting?
- How Do You Respect Crawl Directives Without Slowing Down?
- How Do You Monitor and Adapt Your Request Rate in Production?
- Conclusion
-
What Actually Triggers Rate Limiting When Scraping at Scale?
How to Scrape High-Volume Public Data Without Getting Rate-Limited
Most scrapers fail not because they lack proxies, but because they trigger behavioral rate limits before IP-level limits even apply. A uniform 500ms sleep between requests, a static User-Agent header, or 50 concurrent connections from the same session — any of these signals a bot faster than your IP's reputation does.
Rotating proxies help. Datacenter proxies deliver 99.88% median success and 0.38s median RTT (ProxyWay 2024). But those benchmarks assume you've already solved the behavioral layer. This guide covers both: the rate-limit triggers you control through code, and the IP-layer distribution that handles the ones you don't.
Key Takeaways
- Rate limiting is applied at three independent layers: IP, session/cookie, and behavioral patterns — you have to address all three to sustain high-volume scraping
asyncio.Semaphorewith per-domain concurrency caps is the correct primitive for Python async scrapers;time.sleep()in threads is not equivalent- The
Retry-Afterheader in a 429 response is authoritative — ignoring it and retrying immediately is the fastest way to escalate from rate-limited to IP-banned
Rate limiting is applied at three distinct layers, and they operate independently. Bypassing one doesn't help with the others.
IP-level limits are the most visible. A single IP generating hundreds of requests per minute to the same domain gets rate-limited or blocked — this is what proxies are designed to address. The server sees too much traffic from one source and fires an HTTP 429 with an optional
Retry-Afterheader (RFC 6585) indicating when the client should retry.Session-level limits are subtler. Sites track cookies, session tokens, and authenticated state across requests. If your scraper cycles IPs but reuses the same session cookie, the server can correlate all those requests to one logical identity and rate-limit the account rather than the IP. This is common on e-commerce sites and data portals that require login.
Behavioral limits are the hardest to defeat. Anti-bot systems score each request stream for signals that no human produces:
- Uniform inter-request timing (exactly 1.0s gaps)
- Requests with no referrer header on deep-page URLs
- Sessions that never visit the homepage before hitting the API
- 100% cache misses (no repeated asset requests that browsers generate)
- Perfect sequential URL patterns without any backtracking
The proxy use case that ProxyWay 2024 found growing fastest was e-commerce — which uses exactly this kind of layered detection. Getting past IP-level blocks is the easy part.
[CITATION CAPSULE]
Source: ProxyWay Proxy Market Research 2024 (proxyway.com/research/proxy-market-research-2024)
Finding: E-commerce was the most frequently mentioned scraping use case across 13 major proxy providers. Proxy use grew up to 19× year-over-year for some providers, driven by AI data collection and e-commerce monitoring workloads.
-
How Do You Choose the Right Concurrency Model for High-Volume Scraping?
The right concurrency model determines both your maximum throughput and your ability to stay within per-domain rate limits without blocking your entire pipeline.
For Python, the correct primitives are:
asyncio+aiohttpwithasyncio.Semaphorefor I/O-bound scraping. A semaphore caps concurrent connections per domain without blocking the event loop, which lets you run multiple domains simultaneously at different rate limits:import asyncio import aiohttp async def fetch(session, url, semaphore): async with semaphore: try: async with session.get(url, timeout=aiohttp.ClientTimeout(total=30)) as resp: if resp.status == 429: retry_after = int(resp.headers.get("Retry-After", 60)) await asyncio.sleep(retry_after) return None # Caller re-queues the URL resp.raise_for_status() return await resp.text() except aiohttp.ClientError: return None async def scrape_domain(urls, max_concurrent=5): semaphore = asyncio.Semaphore(max_concurrent) async with aiohttp.ClientSession() as session: tasks = [fetch(session, url, semaphore) for url in urls] return await asyncio.gather(*tasks)The
max_concurrentvalue is your primary rate-control lever. Start at 2-3 per domain and increase only after confirming no 429s over a 5-minute window. Most public data sites allow 5-10 concurrent connections from a single IP before triggering limits; residential proxies let you run this per-IP across many IPs simultaneously.What not to use:
time.sleep()inside a thread pool doesn't give you the same control. Thread-based concurrency with sleep is hard to tune — threads compete for a shared I/O pool, sleep blocks the thread (not the event loop), and you can't easily respond toRetry-Afterwithout adding significant complexity.For Node.js, the equivalent is
p-limitcombined withnode-fetchoraxios:import pLimit from 'p-limit'; import fetch from 'node-fetch'; const limit = pLimit(5); // 5 concurrent per domain async function scrapeUrls(urls) { return Promise.all( urls.map(url => limit(async () => { const res = await fetch(url, { timeout: 30000 }); if (res.status === 429) { const retryAfter = parseInt(res.headers.get('retry-after') || '60', 10); await new Promise(r => setTimeout(r, retryAfter * 1000)); return null; // Re-queue in caller } return res.text(); }) ) ); }
-
How Do You Implement Exponential Backoff and Retry Logic?
Exponential backoff with jitter is the standard retry pattern for rate-limited requests. Plain fixed-interval retries don't work — if 50 scrapers all sleep for exactly 60 seconds and retry at the same time, they trigger a thundering herd that gets them all blocked again.
The correct pattern: multiply the wait time by a base factor on each retry, add random jitter, and respect the
Retry-Afterheader when present:import asyncio import random import aiohttp async def fetch_with_retry(session, url, semaphore, max_retries=4): base_delay = 1.0 for attempt in range(max_retries + 1): async with semaphore: try: async with session.get(url, timeout=aiohttp.ClientTimeout(total=30)) as resp: if resp.status == 429: # Honor Retry-After if present, else use exponential backoff retry_after = resp.headers.get("Retry-After") if retry_after: wait = float(retry_after) else: wait = base_delay * (2 ** attempt) + random.uniform(0, 1) if attempt < max_retries: await asyncio.sleep(wait) continue return None # Exhausted retries if resp.status in (500, 502, 503, 504): # Server errors also get backoff wait = base_delay * (2 ** attempt) + random.uniform(0, 0.5) if attempt < max_retries: await asyncio.sleep(wait) continue resp.raise_for_status() return await resp.text() except asyncio.TimeoutError: wait = base_delay * (2 ** attempt) + random.uniform(0, 0.5) if attempt < max_retries: await asyncio.sleep(wait) return NoneKey details:
Retry-Afteris authoritative per RFC 6585 (MDN HTTP reference). It can be a seconds integer or an HTTP-date. Parse both.- Retry on 429, 500, 502, 503, 504 — not on 403 or 404. A 403 means you're blocked, not throttled; retrying makes it worse.
- Cap maximum retries at 3-5. After that, log the URL and move on — spending 15 minutes retrying one URL while 10,000 others queue up is rarely the right call.
[CITATION CAPSULE]
Source: MDN Web Docs — HTTP 429 Too Many Requests (developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status/429)
Finding: The HTTP 429 response indicates the client has sent too many requests in a given time. A Retry-After header may be included indicating how long to wait. Rate-limiting restrictions may be IP-based, user-based, or cookie-based depending on the server implementation.
-
How Do You Use Proxy Rotation to Spread Request Load?
Proxy rotation moves the IP-level rate limit from "requests per minute from this IP" to "requests per minute across your entire proxy pool." The math is simple: if a site allows 60 requests per minute per IP, and you have 100 IPs in rotation, your theoretical ceiling is 6,000 requests per minute.
The practical ceiling is lower because rotation introduces overhead. The right pattern is to assign a proxy at the session level, not the request level:
import itertools import aiohttp class ProxyRotator: def __init__(self, proxy_list): self._cycle = itertools.cycle(proxy_list) self._current = next(self._cycle) def get(self): return self._current def rotate(self): self._current = next(self._cycle) return self._current async def fetch_with_proxy(session, url, proxy_rotator, semaphore): proxy = proxy_rotator.get() async with semaphore: try: async with session.get( url, proxy=f"http://{proxy}", timeout=aiohttp.ClientTimeout(total=30) ) as resp: if resp.status == 429: # This IP is rate-limited — rotate immediately proxy_rotator.rotate() return None return await resp.text() except aiohttp.ClientProxyConnectionError: proxy_rotator.rotate() return None[UNIQUE INSIGHT] Per-request proxy rotation often performs worse than per-session rotation for rate-limiting purposes. When you rotate on every request, each request establishes a new TCP and TLS connection, adding 150-400ms of overhead per request. More importantly, session-scoped rate limits treat each new session as a new entity, which can exhaust your proxy pool's session budget faster than per-IP limits. Assign one proxy per task/worker, rotate only on failure or after N requests per proxy.
For rotating datacenter proxies, the provider's endpoint handles rotation server-side. You connect to one endpoint and get a different IP on each request (or each session depending on the
sticky_sessionparameter). The ProxyWay 2024 benchmark found datacenter proxy pools can reach hundreds of thousands of IPs with Bright Data and Webshare offering the broadest coverage (40+ countries).
-
How Do You Set Session State to Avoid Behavioral Fingerprinting?
Session state is what separates a "first real visit" from a "scraper returning for data." Sites check:
- Does the session have cookies from a prior visit (set by a landing page)?
- Does the
Refererheader match a plausible navigation path? - Is there an
Accept-Languagethat matches the target locale? - Are there
sec-ch-uaclient hint headers present?
The fix isn't to fake everything — it's to replicate the natural visit path before making data requests. For most public data sites, this means hitting the homepage, following one redirect, letting any session cookies get set, and then requesting the data endpoints:
import aiohttp HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept-Language": "en-US,en;q=0.9", "Accept-Encoding": "gzip, deflate, br", "Sec-Fetch-Site": "same-origin", "Sec-Fetch-Mode": "navigate", "Sec-Fetch-Dest": "document", } async def warm_session(session, base_url): """Visit homepage to establish cookies before scraping data pages.""" async with session.get(base_url, headers=HEADERS) as resp: # Let cookies be set from Set-Cookie headers automatically await resp.read() async def scrape_with_warm_session(base_url, data_urls): connector = aiohttp.TCPConnector(limit=10) async with aiohttp.ClientSession(connector=connector) as session: await warm_session(session, base_url) results = [] for url in data_urls: headers = {**HEADERS, "Referer": base_url} async with session.get(url, headers=headers) as resp: results.append(await resp.text()) return resultsNotice
aiohttp.ClientSessionhandles cookie storage automatically across requests in the same session — you don't need to manually extract and re-send cookies. What you do need is the same session object for the warm-up and the data requests.
-
How Do You Respect Crawl Directives Without Slowing Down?
robots.txtCrawl-delaydirectives andDisallowrules are the formal signals a site uses to communicate acceptable access patterns. Ignoring them is both discourteous and counterproductive — sites with active bot management userobots.txtcompliance as a trust signal; scrapers that honor it often get better treatment.Python's
urllib.robotparserhandles this without third-party dependencies:import urllib.robotparser import asyncio class RobotsCache: def __init__(self): self._parsers = {} async def can_fetch(self, url, user_agent="*"): from urllib.parse import urlparse parsed = urlparse(url) base = f"{parsed.scheme}://{parsed.netloc}" if base not in self._parsers: rp = urllib.robotparser.RobotFileParser() rp.set_url(f"{base}/robots.txt") rp.read() # Synchronous — wrap in asyncio.to_thread in prod self._parsers[base] = rp return self._parsers[base].can_fetch(user_agent, url) def get_crawl_delay(self, base_url, user_agent="*"): rp = self._parsers.get(base_url) if rp: return rp.crawl_delay(user_agent) or 0 return 0The practical tradeoff: If
Crawl-delay: 10is set, following it literally caps you at 6 URLs per minute per IP. With 100 IPs in rotation, that's 600 URLs/minute — still substantial. If the site hasn't set aCrawl-delay, treat 1-2 seconds per IP per domain as a safe default for public data sites.The key insight is that
robots.txtis per-domain, not per-IP. The crawl delay applies to your entire user agent identity, not to each proxy. Rotating IPs to exceed the stated crawl delay is explicitly against the spirit of the directive and often against the site's terms of service.
-
How Do You Monitor and Adapt Your Request Rate in Production?
A static rate limit that works in testing often fails in production because traffic patterns change: the target site deploys new bot detection, other scrapers start competing for the same resources, or your own volume spikes. Production scrapers need adaptive rate control.
The minimum viable monitoring setup tracks three metrics per domain:
Metric Signal Action 429 rate (%) > 5% in 5-min window Halve concurrency, rotate all proxies Success rate (%) < 95% in 5-min window Check for 403s (block) vs 5xx (server load) Median response time (ms) Increasing trend Reduce concurrency before 429s appear An adaptive concurrency controller that responds to these signals:
import asyncio import time from collections import deque class AdaptiveRateLimiter: def __init__(self, initial_concurrency=5, window_seconds=300): self.concurrency = initial_concurrency self.semaphore = asyncio.Semaphore(initial_concurrency) self._window = window_seconds self._responses = deque() # (timestamp, status_code) def record(self, status_code): now = time.monotonic() self._responses.append((now, status_code)) # Prune old entries cutoff = now - self._window while self._responses and self._responses[0][0] < cutoff: self._responses.popleft() @property def rate_429(self): if not self._responses: return 0.0 total_429 = sum(1 for _, s in self._responses if s == 429) return total_429 / len(self._responses) async def maybe_backoff(self): if self.rate_429 > 0.05 and self.concurrency > 1: self.concurrency = max(1, self.concurrency // 2) self.semaphore = asyncio.Semaphore(self.concurrency) await asyncio.sleep(30) # Cool-down periodPair this with structured logging (JSON lines to stdout, shipped to your log aggregator) so you can see rate trends without adding a full observability stack:
import json, time, logging def log_request(url, status, elapsed_ms, proxy): print(json.dumps({ "ts": time.time(), "url": url, "status": status, "elapsed_ms": round(elapsed_ms, 1), "proxy": proxy.split("@")[-1] if "@" in proxy else proxy # Strip credentials }))
-
Conclusion
Rate limiting targets three layers simultaneously: IP address, session identity, and behavioral patterns. Fixing only one means the other two still expose you.
The production checklist:
- Use
asyncio.Semaphorewith per-domain concurrency caps (start at 2-5, tune up) - Implement exponential backoff with jitter; always honor
Retry-Afterheaders - Rotate proxies at the session level, not the request level — assign one proxy per worker
- Warm sessions by visiting the landing page before hitting data URLs
- Track 429 rate in rolling 5-minute windows and halve concurrency if it exceeds 5%
- Respect
robots.txtcrawl delays — they're both ethical and strategically sound
Datacenter proxies at $0.70/GB median cost (ProxyWay 2024) give you the IP distribution you need. Getting the behavioral and session layers right is what keeps those proxies working.
- Use
How to Scrape High-Volume Public Data Without Getting Rate-Limited
Most scrapers fail not because they lack proxies, but because they trigger behavioral rate limits before IP-level limits even apply. A uniform 500ms sleep between requests, a static User-Agent header, or 50 concurrent connections from the same session — any of these signals a bot faster than your IP's reputation does.
Rotating proxies help. Datacenter proxies deliver 99.88% median success and 0.38s median RTT (ProxyWay 2024). But those benchmarks assume you've already solved the behavioral layer. This guide covers both: the rate-limit triggers you control through code, and the IP-layer distribution that handles the ones you don't.
Key Takeaways
- Rate limiting is applied at three independent layers: IP, session/cookie, and behavioral patterns — you have to address all three to sustain high-volume scraping
asyncio.Semaphorewith per-domain concurrency caps is the correct primitive for Python async scrapers;time.sleep()in threads is not equivalent- The
Retry-Afterheader in a 429 response is authoritative — ignoring it and retrying immediately is the fastest way to escalate from rate-limited to IP-banned
Table of Contents
- What Actually Triggers Rate Limiting When Scraping at Scale?
- How Do You Choose the Right Concurrency Model for High-Volume Scraping?
- How Do You Implement Exponential Backoff and Retry Logic?
- How Do You Use Proxy Rotation to Spread Request Load?
- How Do You Set Session State to Avoid Behavioral Fingerprinting?
- How Do You Respect Crawl Directives Without Slowing Down?
- How Do You Monitor and Adapt Your Request Rate in Production?
- FAQ
- Conclusion
What Actually Triggers Rate Limiting When Scraping at Scale?
Rate limiting is applied at three distinct layers, and they operate independently. Bypassing one doesn't help with the others.
IP-level limits are the most visible. A single IP generating hundreds of requests per minute to the same domain gets rate-limited or blocked — this is what proxies are designed to address. The server sees too much traffic from one source and fires an HTTP 429 with an optional Retry-After header (RFC 6585) indicating when the client should retry.
Session-level limits are subtler. Sites track cookies, session tokens, and authenticated state across requests. If your scraper cycles IPs but reuses the same session cookie, the server can correlate all those requests to one logical identity and rate-limit the account rather than the IP. This is common on e-commerce sites and data portals that require login.
Behavioral limits are the hardest to defeat. Anti-bot systems score each request stream for signals that no human produces:
- Uniform inter-request timing (exactly 1.0s gaps)
- Requests with no referrer header on deep-page URLs
- Sessions that never visit the homepage before hitting the API
- 100% cache misses (no repeated asset requests that browsers generate)
- Perfect sequential URL patterns without any backtracking
The proxy use case that ProxyWay 2024 found growing fastest was e-commerce — which uses exactly this kind of layered detection. Getting past IP-level blocks is the easy part.
[CITATION CAPSULE]
Source: ProxyWay Proxy Market Research 2024 (proxyway.com/research/proxy-market-research-2024)
Finding: E-commerce was the most frequently mentioned scraping use case across 13 major proxy providers. Proxy use grew up to 19× year-over-year for some providers, driven by AI data collection and e-commerce monitoring workloads.
[INTERNAL-LINK: "proxy detection methods" → article covering IP fingerprinting and subnet reputation checks]
How Do You Choose the Right Concurrency Model for High-Volume Scraping?
The right concurrency model determines both your maximum throughput and your ability to stay within per-domain rate limits without blocking your entire pipeline.
For Python, the correct primitives are:
asyncio + aiohttp with asyncio.Semaphore for I/O-bound scraping. A semaphore caps concurrent connections per domain without blocking the event loop, which lets you run multiple domains simultaneously at different rate limits:
import asyncio
import aiohttp
async def fetch(session, url, semaphore):
async with semaphore:
try:
async with session.get(url, timeout=aiohttp.ClientTimeout(total=30)) as resp:
if resp.status == 429:
retry_after = int(resp.headers.get("Retry-After", 60))
await asyncio.sleep(retry_after)
return None # Caller re-queues the URL
resp.raise_for_status()
return await resp.text()
except aiohttp.ClientError:
return None
async def scrape_domain(urls, max_concurrent=5):
semaphore = asyncio.Semaphore(max_concurrent)
async with aiohttp.ClientSession() as session:
tasks = [fetch(session, url, semaphore) for url in urls]
return await asyncio.gather(*tasks)
The max_concurrent value is your primary rate-control lever. Start at 2-3 per domain and increase only after confirming no 429s over a 5-minute window. Most public data sites allow 5-10 concurrent connections from a single IP before triggering limits; residential proxies let you run this per-IP across many IPs simultaneously.
What not to use:
time.sleep() inside a thread pool doesn't give you the same control. Thread-based concurrency with sleep is hard to tune — threads compete for a shared I/O pool, sleep blocks the thread (not the event loop), and you can't easily respond to Retry-After without adding significant complexity.
For Node.js, the equivalent is p-limit combined with node-fetch or axios:
import pLimit from 'p-limit';
import fetch from 'node-fetch';
const limit = pLimit(5); // 5 concurrent per domain
async function scrapeUrls(urls) {
return Promise.all(
urls.map(url =>
limit(async () => {
const res = await fetch(url, { timeout: 30000 });
if (res.status === 429) {
const retryAfter = parseInt(res.headers.get('retry-after') || '60', 10);
await new Promise(r => setTimeout(r, retryAfter * 1000));
return null; // Re-queue in caller
}
return res.text();
})
)
);
}
How Do You Implement Exponential Backoff and Retry Logic?
Exponential backoff with jitter is the standard retry pattern for rate-limited requests. Plain fixed-interval retries don't work — if 50 scrapers all sleep for exactly 60 seconds and retry at the same time, they trigger a thundering herd that gets them all blocked again.
The correct pattern: multiply the wait time by a base factor on each retry, add random jitter, and respect the Retry-After header when present:
import asyncio
import random
import aiohttp
async def fetch_with_retry(session, url, semaphore, max_retries=4):
base_delay = 1.0
for attempt in range(max_retries + 1):
async with semaphore:
try:
async with session.get(url, timeout=aiohttp.ClientTimeout(total=30)) as resp:
if resp.status == 429:
# Honor Retry-After if present, else use exponential backoff
retry_after = resp.headers.get("Retry-After")
if retry_after:
wait = float(retry_after)
else:
wait = base_delay * (2 ** attempt) + random.uniform(0, 1)
if attempt < max_retries:
await asyncio.sleep(wait)
continue
return None # Exhausted retries
if resp.status in (500, 502, 503, 504):
# Server errors also get backoff
wait = base_delay * (2 ** attempt) + random.uniform(0, 0.5)
if attempt < max_retries:
await asyncio.sleep(wait)
continue
resp.raise_for_status()
return await resp.text()
except asyncio.TimeoutError:
wait = base_delay * (2 ** attempt) + random.uniform(0, 0.5)
if attempt < max_retries:
await asyncio.sleep(wait)
return None
Key details:
Retry-Afteris authoritative per RFC 6585 (MDN HTTP reference). It can be a seconds integer or an HTTP-date. Parse both.- Retry on 429, 500, 502, 503, 504 — not on 403 or 404. A 403 means you're blocked, not throttled; retrying makes it worse.
- Cap maximum retries at 3-5. After that, log the URL and move on — spending 15 minutes retrying one URL while 10,000 others queue up is rarely the right call.
[CITATION CAPSULE]
Source: MDN Web Docs — HTTP 429 Too Many Requests (developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status/429)
Finding: The HTTP 429 response indicates the client has sent too many requests in a given time. A Retry-After header may be included indicating how long to wait. Rate-limiting restrictions may be IP-based, user-based, or cookie-based depending on the server implementation.
How Do You Use Proxy Rotation to Spread Request Load?
Proxy rotation moves the IP-level rate limit from "requests per minute from this IP" to "requests per minute across your entire proxy pool." The math is simple: if a site allows 60 requests per minute per IP, and you have 100 IPs in rotation, your theoretical ceiling is 6,000 requests per minute.
The practical ceiling is lower because rotation introduces overhead. The right pattern is to assign a proxy at the session level, not the request level:
import itertools
import aiohttp
class ProxyRotator:
def __init__(self, proxy_list):
self._cycle = itertools.cycle(proxy_list)
self._current = next(self._cycle)
def get(self):
return self._current
def rotate(self):
self._current = next(self._cycle)
return self._current
async def fetch_with_proxy(session, url, proxy_rotator, semaphore):
proxy = proxy_rotator.get()
async with semaphore:
try:
async with session.get(
url,
proxy=f"http://{proxy}",
timeout=aiohttp.ClientTimeout(total=30)
) as resp:
if resp.status == 429:
# This IP is rate-limited — rotate immediately
proxy_rotator.rotate()
return None
return await resp.text()
except aiohttp.ClientProxyConnectionError:
proxy_rotator.rotate()
return None
[UNIQUE INSIGHT] Per-request proxy rotation often performs worse than per-session rotation for rate-limiting purposes. When you rotate on every request, each request establishes a new TCP and TLS connection, adding 150-400ms of overhead per request. More importantly, session-scoped rate limits treat each new session as a new entity, which can exhaust your proxy pool's session budget faster than per-IP limits. Assign one proxy per task/worker, rotate only on failure or after N requests per proxy.
For rotating datacenter proxies, the provider's endpoint handles rotation server-side. You connect to one endpoint and get a different IP on each request (or each session depending on the sticky_session parameter). The ProxyWay 2024 benchmark found datacenter proxy pools can reach hundreds of thousands of IPs with Bright Data and Webshare offering the broadest coverage (40+ countries).
[INTERNAL-LINK: "rotating proxy setup Python" → tutorial on configuring rotating proxies with aiohttp and requests]
How Do You Set Session State to Avoid Behavioral Fingerprinting?
Session state is what separates a "first real visit" from a "scraper returning for data." Sites check:
- Does the session have cookies from a prior visit (set by a landing page)?
- Does the
Refererheader match a plausible navigation path? - Is there an
Accept-Languagethat matches the target locale? - Are there
sec-ch-uaclient hint headers present?
The fix isn't to fake everything — it's to replicate the natural visit path before making data requests. For most public data sites, this means hitting the homepage, following one redirect, letting any session cookies get set, and then requesting the data endpoints:
import aiohttp
HEADERS = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.9",
"Accept-Encoding": "gzip, deflate, br",
"Sec-Fetch-Site": "same-origin",
"Sec-Fetch-Mode": "navigate",
"Sec-Fetch-Dest": "document",
}
async def warm_session(session, base_url):
"""Visit homepage to establish cookies before scraping data pages."""
async with session.get(base_url, headers=HEADERS) as resp:
# Let cookies be set from Set-Cookie headers automatically
await resp.read()
async def scrape_with_warm_session(base_url, data_urls):
connector = aiohttp.TCPConnector(limit=10)
async with aiohttp.ClientSession(connector=connector) as session:
await warm_session(session, base_url)
results = []
for url in data_urls:
headers = {**HEADERS, "Referer": base_url}
async with session.get(url, headers=headers) as resp:
results.append(await resp.text())
return results
Notice aiohttp.ClientSession handles cookie storage automatically across requests in the same session — you don't need to manually extract and re-send cookies. What you do need is the same session object for the warm-up and the data requests.
[INTERNAL-LINK: "HTTP headers scraping" → guide on browser-native header ordering and TLS fingerprint matching]
How Do You Respect Crawl Directives Without Slowing Down?
robots.txt Crawl-delay directives and Disallow rules are the formal signals a site uses to communicate acceptable access patterns. Ignoring them is both discourteous and counterproductive — sites with active bot management use robots.txt compliance as a trust signal; scrapers that honor it often get better treatment.
Python's urllib.robotparser handles this without third-party dependencies:
import urllib.robotparser
import asyncio
class RobotsCache:
def __init__(self):
self._parsers = {}
async def can_fetch(self, url, user_agent="*"):
from urllib.parse import urlparse
parsed = urlparse(url)
base = f"{parsed.scheme}://{parsed.netloc}"
if base not in self._parsers:
rp = urllib.robotparser.RobotFileParser()
rp.set_url(f"{base}/robots.txt")
rp.read() # Synchronous — wrap in asyncio.to_thread in prod
self._parsers[base] = rp
return self._parsers[base].can_fetch(user_agent, url)
def get_crawl_delay(self, base_url, user_agent="*"):
rp = self._parsers.get(base_url)
if rp:
return rp.crawl_delay(user_agent) or 0
return 0
The practical tradeoff: If Crawl-delay: 10 is set, following it literally caps you at 6 URLs per minute per IP. With 100 IPs in rotation, that's 600 URLs/minute — still substantial. If the site hasn't set a Crawl-delay, treat 1-2 seconds per IP per domain as a safe default for public data sites.
The key insight is that robots.txt is per-domain, not per-IP. The crawl delay applies to your entire user agent identity, not to each proxy. Rotating IPs to exceed the stated crawl delay is explicitly against the spirit of the directive and often against the site's terms of service.
How Do You Monitor and Adapt Your Request Rate in Production?
A static rate limit that works in testing often fails in production because traffic patterns change: the target site deploys new bot detection, other scrapers start competing for the same resources, or your own volume spikes. Production scrapers need adaptive rate control.
The minimum viable monitoring setup tracks three metrics per domain:
| Metric | Signal | Action |
|---|---|---|
| 429 rate (%) | > 5% in 5-min window | Halve concurrency, rotate all proxies |
| Success rate (%) | < 95% in 5-min window | Check for 403s (block) vs 5xx (server load) |
| Median response time (ms) | Increasing trend | Reduce concurrency before 429s appear |
An adaptive concurrency controller that responds to these signals:
import asyncio
import time
from collections import deque
class AdaptiveRateLimiter:
def __init__(self, initial_concurrency=5, window_seconds=300):
self.concurrency = initial_concurrency
self.semaphore = asyncio.Semaphore(initial_concurrency)
self._window = window_seconds
self._responses = deque() # (timestamp, status_code)
def record(self, status_code):
now = time.monotonic()
self._responses.append((now, status_code))
# Prune old entries
cutoff = now - self._window
while self._responses and self._responses[0][0] < cutoff:
self._responses.popleft()
@property
def rate_429(self):
if not self._responses:
return 0.0
total_429 = sum(1 for _, s in self._responses if s == 429)
return total_429 / len(self._responses)
async def maybe_backoff(self):
if self.rate_429 > 0.05 and self.concurrency > 1:
self.concurrency = max(1, self.concurrency // 2)
self.semaphore = asyncio.Semaphore(self.concurrency)
await asyncio.sleep(30) # Cool-down period
Pair this with structured logging (JSON lines to stdout, shipped to your log aggregator) so you can see rate trends without adding a full observability stack:
import json, time, logging
def log_request(url, status, elapsed_ms, proxy):
print(json.dumps({
"ts": time.time(),
"url": url,
"status": status,
"elapsed_ms": round(elapsed_ms, 1),
"proxy": proxy.split("@")[-1] if "@" in proxy else proxy # Strip credentials
}))
Frequently Asked Questions
What's the difference between a 429 and a 403 when scraping?
A 429 means you've sent too many requests and should slow down — the server is willing to serve you again after a cooldown. A 403 means you're forbidden, usually because you've been identified and blocked rather than just throttled. Exponential backoff fixes 429s; it doesn't fix 403s. A 403 typically requires a proxy rotation, session reset, and often a different approach to the target entirely. Never retry a 403 with the same IP and session.
How many proxies do you need for scraping 100,000 URLs per day?
It depends on the target's tolerance, not just the math. A target allowing 60 requests per minute per IP and a 2-second crawl delay effectively caps you at 30 URLs/minute per IP. At that rate, 100,000 URLs per day requires about 56 hours of IP-time — achievable with 3-4 concurrent IPs running continuously. For stricter sites (10 requests/minute per IP), you'd need 12+ IPs. Start with 5-10 and scale based on actual 429 rate data, not theoretical maximums.
Does adding a random sleep between requests prevent rate limiting?
Partially. Random sleep addresses the uniform-timing detection signal but doesn't address IP-level volume limits or session correlation. A random delay drawn from a normal distribution (mean 2s, std 0.8s) looks more human than a fixed 1s sleep. Combined with proxy rotation and session warm-up, it meaningfully reduces behavioral detection. On its own, random sleep won't help if you're already hitting volume thresholds.
Is it safe to ignore robots.txt when scraping public data?
"Public" means the data is accessible, not that all access patterns are permitted. Most robots.txt files specify Crawl-delay values that throttle high-frequency access without blocking it entirely. Violating them doesn't result in immediate blocks, but it does make you indistinguishable from abusive scrapers, which typically results in harsher rate limits being applied. Honor Disallow rules and respect Crawl-delay — it's also the legally safer position in most jurisdictions for data collection from competitor sites.
Conclusion
Rate limiting targets three layers simultaneously: IP address, session identity, and behavioral patterns. Fixing only one means the other two still expose you.
The production checklist:
- Use
asyncio.Semaphorewith per-domain concurrency caps (start at 2-5, tune up) - Implement exponential backoff with jitter; always honor
Retry-Afterheaders - Rotate proxies at the session level, not the request level — assign one proxy per worker
- Warm sessions by visiting the landing page before hitting data URLs
- Track 429 rate in rolling 5-minute windows and halve concurrency if it exceeds 5%
- Respect
robots.txtcrawl delays — they're both ethical and strategically sound
Datacenter proxies at $0.70/GB median cost (ProxyWay 2024) give you the IP distribution you need. Getting the behavioral and session layers right is what keeps those proxies working.
Frequently Asked Questions
What's the difference between a 429 and a 403 when scraping?
A 429 means you've sent too many requests and should slow down — the server is willing to serve you again after a cooldown. A 403 means you're forbidden, usually because you've been identified and blocked rather than just throttled. Exponential backoff fixes 429s; it doesn't fix 403s. A 403 typically requires a proxy rotation, session reset, and often a different approach to the target entirely. Never retry a 403 with the same IP and session.
How many proxies do you need for scraping 100,000 URLs per day?
It depends on the target's tolerance, not just the math. A target allowing 60 requests per minute per IP and a 2-second crawl delay effectively caps you at 30 URLs/minute per IP. At that rate, 100,000 URLs per day requires about 56 hours of IP-time — achievable with 3-4 concurrent IPs running continuously. For stricter sites (10 requests/minute per IP), you'd need 12+ IPs. Start with 5-10 and scale based on actual 429 rate data, not theoretical maximums.
Does adding a random sleep between requests prevent rate limiting?
Partially. Random sleep addresses the uniform-timing detection signal but doesn't address IP-level volume limits or session correlation. A random delay drawn from a normal distribution (mean 2s, std 0.8s) looks more human than a fixed 1s sleep. Combined with proxy rotation and session warm-up, it meaningfully reduces behavioral detection. On its own, random sleep won't help if you're already hitting volume thresholds.
Is it safe to ignore robots.txt when scraping public data?
"Public" means the data is accessible, not that all access patterns are permitted. Most robots.txt files specify Crawl-delay values that throttle high-frequency access without blocking it entirely. Violating them doesn't result in immediate blocks, but it does make you indistinguishable from abusive scrapers, which typically results in harsher rate limits being applied. Honor Disallow rules and respect Crawl-delay — it's also the legally safer position in most jurisdictions for data collection from competitor sites.