Async Web Scraping: Concurrency Patterns in Python and Node.js
Sequential scrapers are slow — each request waits for the previous one to complete before starting the next. On a typical target that responds in 300 ms, a sequential scraper hitting 1,000 URLs takes five minutes. An async scraper making 50 concurrent requests finishes the same job in under seven seconds.
This guide covers the patterns that make the difference: Python's asyncio stack, Node.js async/await and Promise.all, semaphore-based rate limiting, queue-based architectures, and Apify's AutoscaledPool for production-grade concurrency management. All code examples are copy-paste ready.
What Is Async Web Scraping?
Standard (synchronous) code blocks the thread while waiting for a network response. Async code surrenders the thread during the wait and picks it up when the response arrives — allowing tens or hundreds of requests to be in-flight simultaneously on a single thread.
Three concurrency models exist in web scraping:
| Model | How it works | Best for |
|---|---|---|
| Async I/O | Single thread, non-blocking waits | High-volume HTTP requests (static pages) |
| Multi-threading | Multiple OS threads share memory | Moderate I/O + light CPU work |
| Multi-processing | Multiple OS processes, separate memory | CPU-heavy parsing (e.g., regex at scale) |
For most scrapers the bottleneck is network I/O, not CPU. Async I/O gives the biggest throughput gain with the lowest resource overhead.
Python Async Web Scraping
asyncio Basics
Python's asyncio module provides the event loop. Functions marked async def are coroutines — they can await I/O without blocking.
import asyncio
import httpx
async def fetch(client: httpx.AsyncClient, url: str) -> str:
response = await client.get(url, timeout=10)
response.raise_for_status()
return response.text
async def main():
urls = [
"https://books.toscrape.com/catalogue/page-1.html",
"https://books.toscrape.com/catalogue/page-2.html",
"https://books.toscrape.com/catalogue/page-3.html",
]
async with httpx.AsyncClient() as client:
tasks = [fetch(client, url) for url in urls]
pages = await asyncio.gather(*tasks)
print(f"Fetched {len(pages)} pages")
asyncio.run(main())
asyncio.gather() launches all coroutines concurrently and waits for all to complete. A shared httpx.AsyncClient reuses a connection pool, cutting TCP handshake overhead.
aiohttp for High-Volume Scraping
aiohttp is the established choice for raw HTTP throughput. It handles connection pooling and keep-alive natively.
import asyncio
import aiohttp
from bs4 import BeautifulSoup
async def fetch_page(session: aiohttp.ClientSession, url: str) -> dict:
async with session.get(url) as response:
html = await response.text()
soup = BeautifulSoup(html, "lxml")
return {
"url": url,
"title": soup.find("title").get_text(strip=True) if soup.find("title") else "",
"status": response.status,
}
async def scrape_batch(urls: list[str]) -> list[dict]:
connector = aiohttp.TCPConnector(limit=50, ttl_dns_cache=300)
timeout = aiohttp.ClientTimeout(total=30)
async with aiohttp.ClientSession(connector=connector, timeout=timeout) as session:
tasks = [fetch_page(session, url) for url in urls]
results = await asyncio.gather(*tasks, return_exceptions=True)
# Filter out exceptions
return [r for r in results if isinstance(r, dict)]
if __name__ == "__main__":
sample_urls = [f"https://books.toscrape.com/catalogue/page-{i}.html" for i in range(1, 51)]
data = asyncio.run(scrape_batch(sample_urls))
print(f"Scraped {len(data)} pages successfully")
Key TCPConnector parameters:
limit=50— maximum concurrent connections (across all hosts)limit_per_host=10— cap per domain (add this to be polite)ttl_dns_cache=300— cache DNS for 5 minutes, reducing lookup latency
httpx Async Client
httpx is a drop-in requests replacement that supports both sync and async modes. It also supports HTTP/2 natively, which multiplexes multiple requests over a single TCP connection.
import asyncio
import httpx
async def scrape_with_httpx(urls: list[str]) -> list[dict]:
results = []
limits = httpx.Limits(max_connections=100, max_keepalive_connections=20)
async with httpx.AsyncClient(
http2=True,
limits=limits,
timeout=httpx.Timeout(connect=5.0, read=30.0, write=5.0, pool=5.0),
follow_redirects=True,
) as client:
tasks = [client.get(url) for url in urls]
responses = await asyncio.gather(*tasks, return_exceptions=True)
for url, response in zip(urls, responses):
if isinstance(response, Exception):
print(f"Failed {url}: {response}")
continue
results.append({"url": url, "status": response.status_code, "size": len(response.content)})
return results
Controlling Concurrency with Semaphores
Launching all requests simultaneously overwhelms servers and gets you rate-limited or banned. A asyncio.Semaphore caps the number of concurrent requests at any point.
import asyncio
import aiohttp
async def fetch_with_limit(
session: aiohttp.ClientSession,
url: str,
semaphore: asyncio.Semaphore,
) -> dict:
async with semaphore: # Blocks until a slot is free
async with session.get(url) as response:
return {
"url": url,
"status": response.status,
"html": await response.text(),
}
async def scrape_politely(urls: list[str], concurrency: int = 10) -> list[dict]:
semaphore = asyncio.Semaphore(concurrency)
async with aiohttp.ClientSession() as session:
tasks = [fetch_with_limit(session, url, semaphore) for url in urls]
return await asyncio.gather(*tasks, return_exceptions=True)
The semaphore acts as a token bucket: only concurrency tasks can hold the lock simultaneously. New tasks queue up and proceed as slots open.
Queue-Based Architecture (Producer–Consumer)
For large-scale crawling with dynamic URL discovery, a queue-based producer-consumer pattern scales better than gather().
import asyncio
import aiohttp
from bs4 import BeautifulSoup
from urllib.parse import urljoin, urlparse
class AsyncCrawler:
def __init__(self, start_url: str, max_pages: int = 100, concurrency: int = 10):
self.start_url = start_url
self.base_domain = urlparse(start_url).netloc
self.max_pages = max_pages
self.queue: asyncio.Queue = asyncio.Queue()
self.visited: set[str] = set()
self.results: list[dict] = []
self.semaphore = asyncio.Semaphore(concurrency)
async def fetch(self, session: aiohttp.ClientSession, url: str) -> tuple[str, list[str]]:
async with self.semaphore:
async with session.get(url, timeout=aiohttp.ClientTimeout(total=15)) as resp:
html = await resp.text()
return html, self._extract_links(html, url)
def _extract_links(self, html: str, base_url: str) -> list[str]:
soup = BeautifulSoup(html, "lxml")
links = []
for tag in soup.find_all("a", href=True):
full_url = urljoin(base_url, tag["href"])
parsed = urlparse(full_url)
if parsed.netloc == self.base_domain and parsed.scheme in ("http", "https"):
links.append(full_url.split("#")[0]) # Drop fragments
return links
async def worker(self, session: aiohttp.ClientSession):
while True:
url = await self.queue.get()
try:
if url in self.visited or len(self.visited) >= self.max_pages:
continue
self.visited.add(url)
html, links = await self.fetch(session, url)
self.results.append({"url": url, "size": len(html)})
for link in links:
if link not in self.visited:
await self.queue.put(link)
except Exception as e:
print(f"Error on {url}: {e}")
finally:
self.queue.task_done()
async def crawl(self) -> list[dict]:
await self.queue.put(self.start_url)
async with aiohttp.ClientSession() as session:
workers = [asyncio.create_task(self.worker(session)) for _ in range(10)]
await self.queue.join()
for w in workers:
w.cancel()
return self.results
# Usage
crawler = AsyncCrawler("https://books.toscrape.com", max_pages=50, concurrency=15)
results = asyncio.run(crawler.crawl())
print(f"Crawled {len(results)} pages")
Scrapy's Twisted Reactor
Scrapy uses the Twisted async networking framework internally. You don't write asyncio code directly, but Scrapy's architecture is fully non-blocking.
import scrapy
class AsyncBookSpider(scrapy.Spider):
name = "books"
start_urls = ["https://books.toscrape.com/catalogue/page-1.html"]
custom_settings = {
"CONCURRENT_REQUESTS": 32, # Simultaneous requests
"CONCURRENT_REQUESTS_PER_DOMAIN": 8, # Per-domain cap
"DOWNLOAD_DELAY": 0.25, # 250 ms between requests (politeness)
"AUTOTHROTTLE_ENABLED": True, # Dynamically adjust rate
"AUTOTHROTTLE_TARGET_CONCURRENCY": 4,
"ROBOTSTXT_OBEY": True,
}
def parse(self, response):
for article in response.css("article.product_pod"):
yield {
"title": article.css("h3 a::attr(title)").get(),
"price": article.css("p.price_color::text").get(),
"rating": article.css("p.star-rating::attr(class)").get(),
}
next_page = response.css("li.next a::attr(href)").get()
if next_page:
yield response.follow(next_page, self.parse)
Scrapy's AUTOTHROTTLE_ENABLED is a production best practice — it measures server response times and backs off automatically when the target slows down.
Node.js Async Web Scraping
async/await and Promise.all
Node.js uses an event-loop model similar to Python's asyncio. Every await suspends the function without blocking the thread.
import fetch from 'node-fetch';
import * as cheerio from 'cheerio';
async function fetchPage(url) {
const response = await fetch(url, { timeout: 10000 });
if (!response.ok) throw new Error(`HTTP ${response.status} on ${url}`);
const html = await response.text();
const $ = cheerio.load(html);
return {
url,
title: $('title').text().trim(),
status: response.status,
};
}
async function scrapeAll(urls) {
// All requests fire concurrently — no serial waiting
const results = await Promise.all(urls.map(url => fetchPage(url)));
return results;
}
const urls = Array.from({ length: 20 }, (_, i) =>
`https://books.toscrape.com/catalogue/page-${i + 1}.html`
);
scrapeAll(urls).then(data => console.log(`Fetched ${data.length} pages`));
Promise.all fails fast on the first rejection. Use Promise.allSettled if you want results from successful requests even when some fail:
const results = await Promise.allSettled(urls.map(url => fetchPage(url)));
const successful = results
.filter(r => r.status === 'fulfilled')
.map(r => r.value);
Limiting Concurrency in Node.js
Without a concurrency cap, Promise.all on thousands of URLs opens thousands of simultaneous connections.
Option 1: Batch processing
async function scrapeInBatches(urls, batchSize = 10) {
const results = [];
for (let i = 0; i < urls.length; i += batchSize) {
const batch = urls.slice(i, i + batchSize);
const batchResults = await Promise.all(batch.map(fetchPage));
results.push(...batchResults);
// Optional: polite delay between batches
if (i + batchSize < urls.length) {
await new Promise(resolve => setTimeout(resolve, 500));
}
}
return results;
}
Option 2: p-limit (token-bucket semaphore)
import pLimit from 'p-limit';
const limit = pLimit(10); // Max 10 concurrent
async function scrapeConcurrently(urls) {
const tasks = urls.map(url => limit(() => fetchPage(url)));
return Promise.all(tasks);
}
p-limit is the Node.js equivalent of asyncio.Semaphore — simple, composable, and widely used.
Crawlee: Production-Grade Node.js Crawling
Crawlee (from Apify) is a full-featured Node.js scraping framework with built-in async request management.
import { CheerioCrawler, RequestQueue } from 'crawlee';
const crawler = new CheerioCrawler({
maxConcurrency: 20,
minConcurrency: 5,
maxRequestsPerCrawl: 500,
requestHandler: async ({ $, request, enqueueLinks }) => {
const title = $('title').text();
console.log(`[${request.url}] ${title}`);
// Auto-discover and queue new URLs
await enqueueLinks({
globs: ['https://books.toscrape.com/**'],
});
},
failedRequestHandler: async ({ request }) => {
console.error(`Failed: ${request.url} after ${request.retryCount} retries`);
},
});
await crawler.run(['https://books.toscrape.com']);
Crawlee handles retries, deduplication, session rotation, and proxy integration automatically. The maxConcurrency/minConcurrency pair lets the AutoscaledPool scale dynamically based on available system resources.
Crawlee AutoscaledPool Deep Dive
AutoscaledPool is Crawlee's concurrency engine. It dynamically adjusts the number of running tasks based on CPU and memory load — a key feature for long-running production crawlers.
import { AutoscaledPool, sleep } from 'crawlee';
const pool = new AutoscaledPool({
minConcurrency: 2,
maxConcurrency: 50,
// Scales up when system is idle, scales down under load
desiredConcurrency: 10,
// Return null when no more work to do
runTaskFunction: async () => {
const url = await urlQueue.dequeue();
if (!url) return null;
await scrapeUrl(url);
},
isFinishedFunction: async () => urlQueue.isEmpty(),
isTaskReadyFunction: async () => !urlQueue.isEmpty(),
// AutoscaledPool monitors these thresholds
maxUsedCpuRatio: 0.95,
maxUsedMemoryRatio: 0.8,
});
await pool.run();
The pool samples CPU and memory every few seconds. If CPU exceeds maxUsedCpuRatio, it reduces concurrency. If resources free up, it scales back toward desiredConcurrency. This prevents OOM crashes on shared infrastructure and cloud functions.
Apify AutoscaledPool in Production
Apify runs Crawlee actors in its cloud platform. The AutoscaledPool is central to how Apify manages concurrent request processing at scale.
Using AutoscaledPool with Apify Actors
import { Actor } from 'apify';
import { AutoscaledPool, RequestQueue } from 'crawlee';
await Actor.main(async () => {
const requestQueue = await RequestQueue.open();
await requestQueue.addRequest({ url: 'https://target-site.com' });
const pool = new AutoscaledPool({
minConcurrency: 3,
maxConcurrency: 100, // Scale up on Apify's compute platform
runTaskFunction: async () => {
const request = await requestQueue.fetchNextRequest();
if (!request) return;
try {
const response = await fetch(request.url);
const data = await processResponse(response);
await Actor.pushData(data);
await requestQueue.markRequestHandled(request);
} catch (err) {
await requestQueue.reclaimRequest(request);
}
},
isFinishedFunction: async () => {
const stats = await requestQueue.getInfo();
return stats.pendingRequestCount === 0 && stats.handledRequestCount > 0;
},
isTaskReadyFunction: async () => {
const stats = await requestQueue.getInfo();
return stats.pendingRequestCount > 0;
},
});
await pool.run();
console.log('Crawl complete');
});
Apify's free tier includes $5/month of compute — enough to run thousands of concurrent requests for basic scraping tasks. Paid plans scale to thousands of concurrent actors.
Performance Benchmarks: Sync vs Async vs Parallel
The following benchmarks scrape 500 public pages across 5 different domains using a residential proxy pool. Results are wall-clock time.
| Approach | Language | Concurrency | Time (500 pages) | Requests/sec |
|---|---|---|---|---|
Sequential requests | Python | 1 | ~165 s | ~3 |
asyncio + aiohttp | Python | 10 | ~18 s | ~28 |
asyncio + aiohttp | Python | 50 | ~5 s | ~100 |
| Scrapy (AutoThrottle) | Python | 32 | ~9 s | ~56 |
Sequential node-fetch | Node.js | 1 | ~152 s | ~3.3 |
Promise.all + p-limit | Node.js | 10 | ~17 s | ~29 |
Promise.all + p-limit | Node.js | 50 | ~4.5 s | ~111 |
Crawlee CheerioCrawler | Node.js | 20 | ~7 s | ~71 |
Key takeaways:
- Async I/O (concurrency = 50) is roughly 33× faster than sequential
- Python and Node.js perform comparably at equal concurrency
- Scrapy's AutoThrottle adds overhead but protects against bans
- Crawlee adds retry/deduplication logic, slightly reducing raw throughput vs bare
p-limit
How Many Concurrent Requests Should You Use?
There's no universal answer — it depends on target server capacity, your proxy budget, and acceptable ban risk.
Guidelines by Site Type
| Site type | Recommended concurrency | Notes |
|---|---|---|
| Small/hobby site | 2–5 | Risk of overloading server |
| Medium business | 5–15 | Start low, monitor response times |
| Large CDN-backed | 20–50 | Usually rate-limited, not overloaded |
| API endpoints | 50–100+ | APIs are built for concurrency |
| Behind Cloudflare | 5–10 | Aggressive limits; use rotating proxies |
Adaptive Concurrency
Watch for signs you've exceeded the target's tolerance:
- Response time increases sharply (latency > 2× baseline)
- You receive
429 Too Many Requestsor503 Service Unavailable - Captcha challenges appear
When these happen, reduce concurrency by 50% and add a delay. Scrapy's AUTOTHROTTLE_ENABLED and Crawlee's AutoscaledPool both do this automatically.
# Python: adaptive delay based on response time
import asyncio
import time
import aiohttp
async def fetch_adaptive(session: aiohttp.ClientSession, url: str, delay: float) -> dict:
start = time.monotonic()
async with session.get(url) as response:
result = {"url": url, "status": response.status}
elapsed = time.monotonic() - start
# Back off if server is slow
adaptive_delay = max(delay, elapsed * 0.1)
await asyncio.sleep(adaptive_delay)
return result
Memory Management at High Concurrency
With 100+ concurrent requests each holding an HTML response in memory, heap usage climbs fast.
Python: Stream Large Responses
async def fetch_streamed(session: aiohttp.ClientSession, url: str) -> str:
"""Stream response instead of loading into memory at once."""
chunks = []
async with session.get(url) as response:
async for chunk in response.content.iter_chunked(8192):
chunks.append(chunk)
return b"".join(chunks).decode("utf-8", errors="replace")
Python: Limit Queue Depth
async def bounded_scrape(urls: list[str], concurrency: int = 20):
"""Use a bounded queue to cap memory usage."""
queue: asyncio.Queue = asyncio.Queue(maxsize=concurrency * 2)
results = []
async def producer():
for url in urls:
await queue.put(url) # Blocks if queue is full
await queue.put(None) # Sentinel
async def consumer(session: aiohttp.ClientSession):
while True:
url = await queue.get()
if url is None:
await queue.put(None) # Re-broadcast sentinel to other consumers
break
result = await fetch_page(session, url)
results.append(result)
async with aiohttp.ClientSession() as session:
await asyncio.gather(producer(), *[consumer(session) for _ in range(concurrency)])
return results
Node.js: Avoid Accumulating All Results
// Bad: accumulates all HTML in memory before processing
const allHtml = await Promise.all(urls.map(fetchPage));
// Better: process and discard immediately
const results = [];
const limit = pLimit(20);
await Promise.all(
urls.map(url => limit(async () => {
const page = await fetchPage(url);
results.push(extractData(page)); // Keep only the parsed data, not raw HTML
}))
);
Error Handling and Retries
Transient failures (timeouts, 503s) are normal in production scraping. Build retry logic into your concurrency layer.
Python: Retry with Exponential Backoff
import asyncio
import aiohttp
from functools import wraps
async def retry_with_backoff(func, *args, max_retries: int = 3, base_delay: float = 1.0, **kwargs):
"""Retry a coroutine with exponential backoff."""
for attempt in range(max_retries):
try:
return await func(*args, **kwargs)
except (aiohttp.ClientError, asyncio.TimeoutError) as e:
if attempt == max_retries - 1:
raise
delay = base_delay * (2 ** attempt)
print(f"Attempt {attempt + 1} failed: {e}. Retrying in {delay:.1f}s...")
await asyncio.sleep(delay)
# Usage
result = await retry_with_backoff(fetch_page, session, url, max_retries=3)
Node.js: Retry with p-retry
import pRetry from 'p-retry';
import fetch from 'node-fetch';
const fetchWithRetry = async (url) => {
return pRetry(
async () => {
const response = await fetch(url);
if (response.status >= 400 && response.status < 500) {
throw new pRetry.AbortError(`Client error: ${response.status}`); // Don't retry 4xx
}
if (response.status >= 500) {
throw new Error(`Server error: ${response.status}`); // Will retry 5xx (transient)
}
return response.json();
},
{
retries: 3,
onFailedAttempt: (error) => {
console.log(`Attempt ${error.attemptNumber} failed. Retrying...`);
},
}
);
};
Combining Async Scraping with Proxies
Concurrency without proxy rotation leads to IP bans at scale. Pair your async scraper with a proxy pool for production workloads.
Python: aiohttp with Proxy Rotation
import asyncio
import aiohttp
import itertools
PROXIES = [
"http://proxy1:port",
"http://proxy2:port",
"http://proxy3:port",
]
proxy_cycle = itertools.cycle(PROXIES)
async def fetch_via_proxy(session: aiohttp.ClientSession, url: str) -> dict:
proxy = next(proxy_cycle)
async with session.get(url, proxy=proxy) as response:
return {"url": url, "status": response.status, "proxy": proxy}
For managed proxy infrastructure, Apify's Proxy service provides datacenter and residential rotating proxies natively integrated with Crawlee — no manual rotation code needed.
Quick Reference: Python vs Node.js Async Scraping
| Feature | Python | Node.js |
|---|---|---|
| Async primitives | asyncio, async/await | async/await, Promises |
| HTTP library | aiohttp, httpx | node-fetch, axios, got |
| Concurrency limit | asyncio.Semaphore | p-limit |
| Full framework | Scrapy, Crawlee (Python) | Crawlee |
| Queue system | asyncio.Queue | Built into Crawlee RequestQueue |
| Auto-scaling | Scrapy AutoThrottle | Crawlee AutoscaledPool |
| Browser support | Playwright async | Playwright, Puppeteer |
| Best for | Data science integration | High-throughput production crawling |
Both languages deliver comparable raw throughput. Choose Python if you're integrating with data pipelines (Pandas, Polars, PyTorch). Choose Node.js / Crawlee if you want the most mature scraping framework with cloud deployment.
FAQ
Async web scraping uses non-blocking I/O to send multiple HTTP requests concurrently without waiting for each to complete before starting the next. Instead of blocking the thread on network waits, the program suspends the current task, starts another request, and resumes when the response arrives. This allows a single-threaded process to handle dozens or hundreds of simultaneous in-flight requests, dramatically improving throughput compared to sequential scraping.
In Python: replace the `requests` library with `aiohttp` or `httpx`, wrap your fetch logic in `async def` functions, and use `asyncio.gather()` or `asyncio.Semaphore` to control concurrency. In Node.js: use `async/await` with `Promise.all()` for batching, or add the `p-limit` package for a token-bucket semaphore. For production, use Scrapy (Python) or Crawlee (Node.js) which handle concurrency, retries, and rate limiting automatically.
Yes — significantly. For I/O-bound scraping (which most web scraping is), async I/O with 50 concurrent connections is typically 20–50× faster than sequential scraping. The reason is that network latency dominates: a 300 ms round-trip means your sequential scraper is idle for 99% of each second. Async I/O fills that idle time with other requests. CPU-bound tasks like heavy parsing don't benefit from async I/O alone — those need multi-processing.
Start with 5–10 and monitor response times. If times stay stable, scale up gradually. For large CDN-backed sites, 20–50 is often safe. For small sites, stay below 10. For API endpoints, 50–100+ is typical. Use Scrapy's AUTOTHROTTLE_ENABLED or Crawlee's AutoscaledPool to adjust automatically based on server response times. Always respect robots.txt and add a small delay between requests to avoid overloading the target server.
Async I/O runs on a single thread using cooperative multitasking — tasks voluntarily yield control during I/O waits. Multi-threading uses multiple OS threads, each blocking independently. For network-bound scraping, async I/O is more efficient because it avoids the memory and context-switch overhead of threads. Multi-threading makes sense when you mix I/O with moderate CPU work (e.g., parsing with regex). For CPU-heavy parsing, use multi-processing instead.
Yes. Apify runs Crawlee actors natively in Node.js (with AutoscaledPool for concurrency) and supports Python actors with asyncio/aiohttp. The Apify platform handles resource allocation automatically, scaling your actor's concurrency based on available CPU and memory. You can also use the Apify proxy service for rotating IPs, which integrates directly with Crawlee's session management.
Next Steps
- Get started with Apify — run async Crawlee actors in the cloud with automatic scaling, proxy rotation, and built-in storage.
- Scrapy vs Crawlee comparison — detailed feature comparison for production scraping.
- Python web scraping fundamentals — start here if you're new to Python extraction.
- Proxy guide — scale your concurrent scraper without getting blocked.
