You've got a list of 50,000 URLs to scrape. You fire up a for loop with requests.get(), and an hour later, you've processed 3,000. The bottleneck isn't your machine - it's waiting for each HTTP response before sending the next request.
Async scraping with asyncio and HTTPX changes the equation completely. Here's how to build scrapers that handle thousands of concurrent requests without melting your machine or getting banned.
Why Async Matters for Scraping
Scraping is I/O-bound work. For every request, your program spends 200-2000ms waiting for a response. In a synchronous loop, that's dead time - your CPU sits idle while the network does its thing.
Async lets you send hundreds of requests simultaneously. While you're waiting for response #1, you've already fired off requests #2 through #500. The CPU utilization is the same, but throughput goes through the roof.
# Sync: ~3 requests/second
import requests
for url in urls:
response = requests.get(url)
process(response)
# Async: ~200+ requests/second
import httpx
import asyncio
async def scrape(urls):
async with httpx.AsyncClient() as client:
tasks = [client.get(url) for url in urls]
responses = await asyncio.gather(*tasks)
for r in responses:
process(r)But the naive async approach above will blow up at scale. Here's how to do it properly.
Connection Pooling
HTTPX's AsyncClient maintains a connection pool. By default, it keeps 100 connections - which is fine for most cases, but if you're hitting one domain with thousands of requests, you want to tune it:
import httpx
limits = httpx.Limits(
max_connections=100, # Total connection pool size
max_keepalive_connections=20, # Persistent connections to reuse
keepalive_expiry=30, # Seconds before closing idle connections
)
async with httpx.AsyncClient(limits=limits, timeout=30.0) as client:
# All requests share this pool
passThe key insight: reuse connections. TLS handshakes are expensive. A persistent connection pool means you pay the TLS cost once per host, then reuse that connection for hundreds of requests.
Semaphore-Based Rate Limiting
Firing 50,000 requests simultaneously will get you IP-banned in seconds. You need a concurrency limiter:
import asyncio
import httpx
CONCURRENCY = 50 # Max simultaneous requests
async def fetch(client, url, semaphore):
async with semaphore:
try:
response = await client.get(url, timeout=15.0)
return response
except httpx.RequestError:
return None
async def scrape(urls):
semaphore = asyncio.Semaphore(CONCURRENCY)
async with httpx.AsyncClient() as client:
tasks = [fetch(client, url, semaphore) for url in urls]
results = await asyncio.gather(*tasks)
return [r for r in results if r is not None]The Semaphore(50) ensures at most 50 requests are in-flight at any time. The rest queue up and fire as slots open. This gives you consistent throughput without overwhelming the target or your system.
Smart Retry with Exponential Backoff
Network requests fail. Connections time out, servers return 503s, rate limiters kick in. A good scraper handles this gracefully:
import asyncio
import random
async def fetch_with_retry(client, url, semaphore, max_retries=3):
async with semaphore:
for attempt in range(max_retries):
try:
response = await client.get(url, timeout=15.0)
if response.status_code == 429:
# Rate limited - back off
wait = (2 ** attempt) + random.uniform(0, 1)
await asyncio.sleep(wait)
continue
if response.status_code >= 500:
# Server error - retry
await asyncio.sleep(1)
continue
return response
except httpx.RequestError:
if attempt < max_retries - 1:
await asyncio.sleep(1)
continue
return None
return NoneThe exponential backoff with jitter (2 ** attempt + random) prevents thundering herd problems - when all your retries fire at the same time and hit the rate limiter again.
Per-Domain Rate Limiting
If you're scraping multiple domains, you don't want a single slow domain blocking everything. Use per-domain semaphores:
from collections import defaultdict
from urllib.parse import urlparse
class DomainRateLimiter:
def __init__(self, per_domain=10, delay=0.1):
self.semaphores = defaultdict(lambda: asyncio.Semaphore(per_domain))
self.delay = delay
async def acquire(self, url):
domain = urlparse(url).netloc
semaphore = self.semaphores[domain]
await semaphore.acquire()
await asyncio.sleep(self.delay) # Minimum delay between requests
def release(self, url):
domain = urlparse(url).netloc
self.semaphores[domain].release()
limiter = DomainRateLimiter(per_domain=5, delay=0.2)This gives each domain its own concurrency limit. You can hammer api-a.com at 5 concurrent requests while simultaneously hitting api-b.com at 5 more - without either affecting the other.
Processing Results in Batches
Don't try to hold 50,000 responses in memory. Process them as they complete:
import asyncio
import httpx
async def process_batch(client, urls, semaphore, callback):
async def fetch_and_process(url):
async with semaphore:
try:
response = await client.get(url, timeout=15.0)
await callback(url, response)
except httpx.RequestError as e:
await callback(url, None)
# Process in chunks to avoid creating 50k coroutines at once
chunk_size = 1000
for i in range(0, len(urls), chunk_size):
chunk = urls[i:i + chunk_size]
tasks = [fetch_and_process(url) for url in chunk]
await asyncio.gather(*tasks)
async def save_result(url, response):
if response and response.status_code == 200:
# Write to file, database, etc.
data = response.json()
# ... process data ...
async def main():
semaphore = asyncio.Semaphore(50)
async with httpx.AsyncClient() as client:
await process_batch(client, all_urls, semaphore, save_result)Chunking at 1,000 means you never have more than 1,000 pending coroutines, keeping memory usage predictable.
Putting It All Together
Here's a production-ready async scraper structure:
import asyncio
import httpx
import random
import time
from dataclasses import dataclass
@dataclass
class ScrapeResult:
url: str
status: int
data: dict | None
error: str | None
class AsyncScraper:
def __init__(self, concurrency=50, timeout=15, max_retries=3):
self.concurrency = concurrency
self.timeout = timeout
self.max_retries = max_retries
self.results = []
async def fetch(self, client, url, semaphore):
async with semaphore:
for attempt in range(self.max_retries):
try:
response = await client.get(url, timeout=self.timeout)
if response.status_code == 429:
wait = (2 ** attempt) + random.uniform(0, 1)
await asyncio.sleep(wait)
continue
return ScrapeResult(
url=url,
status=response.status_code,
data=response.json() if response.status_code == 200 else None,
error=None
)
except httpx.RequestError as e:
if attempt == self.max_retries - 1:
return ScrapeResult(url=url, status=0, data=None, error=str(e))
await asyncio.sleep(1)
async def run(self, urls):
semaphore = asyncio.Semaphore(self.concurrency)
limits = httpx.Limits(max_connections=self.concurrency)
async with httpx.AsyncClient(limits=limits) as client:
tasks = [self.fetch(client, url, semaphore) for url in urls]
self.results = await asyncio.gather(*tasks)
return self.results
# Usage
scraper = AsyncScraper(concurrency=50)
results = asyncio.run(scraper.run(urls))
success = [r for r in results if r.status == 200]
failed = [r for r in results if r.error]
print(f"Success: {len(success)}, Failed: {len(failed)}")Performance Numbers
Real numbers from production scrapers I've built:
| Approach | URLs | Time | Throughput |
|---|---|---|---|
Sync requests |
10,000 | ~55 min | 3 req/s |
| Async HTTPX (50 concurrent) | 10,000 | ~3 min | 55 req/s |
| Async HTTPX (200 concurrent) | 10,000 | ~50 sec | 200 req/s |
| Multiprocessing + Async (4 workers × 50) | 10,000 | ~50 sec | 200 req/s |
The sweet spot is usually 50-100 concurrent connections. Beyond that, you hit diminishing returns - the target's rate limiter kicks in, or your own network becomes the bottleneck.
Key Takeaways
- Async scraping is 20-60x faster than synchronous for I/O-bound workloads
- Always use a semaphore to cap concurrency - unbounded async will get you banned
- Reuse connections via
AsyncClient- don't create a new client per request - Implement exponential backoff with jitter for retries
- Process results in chunks to keep memory predictable
- 50-100 concurrent connections is the sweet spot for most targets
- Per-domain rate limiting is essential when scraping multiple hosts
The goal isn't maximum speed - it's maximum reliable throughput. A scraper that runs at 50 req/s consistently is better than one that bursts to 500 and gets blocked after 10 seconds.
Always respect robots.txt and terms of service. Scrape responsibly.