Scaling Published Jul 12, 2026 · 2 min read · 508 words
Scraping at scale: queues, caching, and not getting banned
How to take a working scraper to millions of pages without melting the target or getting banned: work queues, bounded concurrency, on-disk caching, dedup, retries with backoff, and polite scheduling.
A scraper that works on ten pages is a script. A scraper that works on ten million pages is a system. The difference isn't parsing. It's scheduling, caching, and politeness. This guide is the architecture between "it ran once" and "it runs every night without getting banned."
The four problems of scale
- Throughput without melting the target.
- Reliability. One 500 shouldn't kill the crawl.
- Politeness. You're a guest on someone else's server.
- Dedup and resumability. Never request the same URL twice. Never lose progress.
Use a work queue
Never crawl with a bare for url in urls: loop. Put URLs in a queue and pull from it. A queue gives you resumability, bounded concurrency, and a place to put newly discovered URLs.
import queue, threading
work = queue.Queue()
for url in seed_urls:
work.put(url)
seen = set()
def worker():
while True:
url = work.get()
if url in seen:
work.task_done()
continue
seen.add(url)
try:
for new_url in crawl(url):
work.put(new_url)
except Exception as e:
log(e)
finally:
work.task_done()
threads = [threading.Thread(target=worker, daemon=True) for _ in range(4)]
for t in threads: t.start()
work.join()
For anything beyond a laptop, use a real queue. Redis, RabbitMQ, or a database-backed queue. Scrapy ships its own scheduler and is the easiest path for most teams.
Bounded concurrency
More concurrency is not more throughput. Past a point it's just more 429s and more load on a server that didn't ask to be crawled. Two rules:
- Cap concurrent connections per domain (2 to 5), not just globally.
- Cap requests per second per domain, with jitter.
# asyncio example: per-domain semaphore + rate limit
import asyncio, time
sem = asyncio.Semaphore(3)
last = {}
async def fetch_one(session, url, domain):
async with sem:
now = time.monotonic()
wait = max(0, last.get(domain, 0) + 1.0 - now) # 1 req/sec/domain
await asyncio.sleep(wait)
last[domain] = time.monotonic()
async with session.get(url) as resp:
return await resp.text()
Cache responses to disk
Caching is the highest-ROI thing you can do. It makes you faster, cheaper, and more polite all at once. You never re-request a page you already have.
import hashlib, pathlib, json
CACHE = pathlib.Path("cache")
CACHE.mkdir(exist_ok=True)
def cache_key(url):
return hashlib.sha256(url.encode()).hexdigest()
def get_cached(url):
p = CACHE / f"{cache_key(url)}.json"
if p.exists():
return json.loads(p.read_text())["body"]
return None
def set_cached(url, body):
p = CACHE / f"{cache_key(url)}.json"
p.write_text(json.dumps({"url": url, "body": body}))
During development this is a lifesaver. You run the parser fifty times against the same cached HTML instead of hitting the site fifty times.
Retries with backoff
Networks fail. Retry, but retry well:
- Retry only on transient errors (timeouts, 5xx, 429). Never retry a 404.
- Use exponential backoff with jitter.
- Cap the attempt count.
async def fetch(session, url, attempts=4):
for i in range(attempts):
try:
resp = await session.get(url)
if resp.status == 200:
return await resp.text()
if resp.status in (404, 410):
return None
if resp.status == 429:
wait = int(resp.headers.get("Retry-After", 2 ** i))
await asyncio.sleep(wait)
continue
if resp.status >= 500:
await asyncio.sleep(2 ** i + random.random())
continue
return None
except (asyncio.TimeoutError, ClientError):
await asyncio.sleep(2 ** i + random.random())
return None
Dedup
Normalize URLs before you check seen:
from urllib.parse import urlsplit, urlunsplit, parse_qsl, urlencode
def normalize(url):
s = urlsplit(url)
scheme = s.scheme.lower()
netloc = s.netloc.lower()
if netloc.endswith(":80"): netloc = netloc[:-3]
if netloc.endswith(":443"): netloc = netloc[:-4]
query = urlencode(sorted(parse_qsl(s.query)))
return urlunsplit((scheme, netloc, s.path.rstrip("/") or "/", query, ""))
For crawls bigger than memory, swap the set for a Bloom filter (pybloom_live) or a Redis set.
Be polite
- Set a per-domain crawl delay. 1 to 3 seconds is a reasonable default.
- Honor Retry-After and 429.
- Identify yourself with a custom User-Agent that includes a contact URL when scraping permitted targets.
- Respect robots.txt. Scrapy does this by default with
ROBOTSTXT_OBEY = True.
Scrapy vs roll-your-own
| Need | Recommendation |
|---|---|
| One page, one-off | requests |
| A few hundred pages | requests plus a queue plus cache |
| A real crawl with scheduling and pipelines | Scrapy |
| JS-rendered at scale | Playwright plus scrapy-playwright, or a managed service |
Scrapy gives you the scheduler, dedup, retries, throttling, and item pipelines you'd otherwise write. And scrapy-playwright adds the browser layer when you need it.
Key takeaways
- Crawl from a queue, not a loop.
- Cap concurrency per domain. Add a rate limit with jitter.
- Cache every response. Never re-request.
- Retry transient errors with exponential backoff and jitter. Never retry 4xx.
- Normalize and dedup URLs. Use a Bloom filter when the set outgrows memory.
- Prefer Scrapy once you have a real crawl.
Frequently Asked Questions
How many concurrent requests is safe when scraping?
There's no universal number. A safe rule is 2 to 5 connections per domain, and keep your average rate well under what one fast human clicker would do. Start at 1 or 2 concurrent. Increase only if the server's response times and status codes stay healthy.
Should I cache scraped HTML to disk?
Yes, almost always. Caching raw responses means you never re-request a page during development or after a parser bug. It's faster and dramatically more polite to the target. A simple file-per-URL or SQLite cache is enough for most projects.
Scrapy or requests for a large crawl?
Use requests for small one-off scrapers. Use Scrapy for anything that grows into a real crawl. Scrapy gives you a scheduler, concurrency control, retries, dedup, and an item pipeline for free. That's exactly the scaffolding you'd otherwise rebuild yourself.
How do I deduplicate URLs in a crawler?
Normalize URLs (lowercase scheme and host, strip default ports, sort query params, drop fragments) and keep a persistent set of what you've already enqueued. For very large crawls, use a Bloom filter or a Redis-backed set instead of an in-process Python set.
Keep reading
Building search infrastructure for 1M+ queries a month: the stuff nobody tells you
A deep, no-bullshit technical dive into building search infrastructure that handles over 1 million queries per month. Inverted index internals, posting list compression, WAND/MaxScore scoring, Go hot-path code, five-layer cache hierarchies, mmap and SSD reality, tiered segment merging, tail latency, and the non-obvious stuff that actually kills you at scale.
searchinfrastructureinverted-indexgoWeb scraping ethics and robots.txt: the lines you don't cross
A clear, practical guide to scraping ethically and legally: reading robots.txt, Terms of Service, the CFAA and EU equivalents, login walls, personal data, and the difference between 'public' and 'allowed'.
ethicsrobots-txtlegaltosBypassing anti-bot protections: TLS, fingerprints, and Cloudflare
A frank guide to anti-bot defenses and how scrapers get past them: TLS/JA3 fingerprinting, Cloudflare's challenge, PerimeterX, CAPTCHAs, residential proxies, and the libraries (curl_cffi, Camoufox) that actually work in 2026.
anti-botcloudflaretls-fingerprintproxies
Found this useful? Cite it as: webscraping.space. “Scraping at scale: queues, caching, and not getting banned.” https://webscraping.space/blog/scraping-at-scale. Published 2026-07-12.