Your cart is currently empty!
Category: Uncategorized
-
Building scraping pipelines with Prefect 3 in 2026
Building scraping pipelines with Prefect 3 in 2026
Building scraping pipelines with Prefect 3 is the right answer when your scraping has grown beyond a single Scrapy project but does not yet need full Apache Airflow’s complexity. Prefect 3 (released 2024) is the third major version of Prefect’s orchestration platform, and it brought meaningful changes from Prefect 2: simpler async support, faster task execution, work pools that decouple scheduling from execution, and a redesigned dashboard. For scraping specifically, Prefect 3 fits the pattern of “fetch URLs, parse them, store results, retry failures, schedule it all” with much less ceremony than Airflow.
This guide covers Prefect 3 for scraping pipelines in 2026: flows and tasks, work pools and workers, retries and concurrency control, scheduling, and a complete production example that fetches URLs, runs them through Playwright when needed, parses results, and stores to Postgres. By the end you will have a working pipeline pattern you can adapt to your specific targets.
Why Prefect for scraping
Prefect’s strengths for scraper pipelines:
- Python-native: flows and tasks are decorated Python functions, no DAG syntax to learn
- Async-friendly: tasks can be
async defand run concurrently - Built-in retries: configurable per task with exponential backoff
- Work pools and workers: decouple “what to run” from “where to run it”
- Cron, interval, and event-based scheduling: flexible
- Observability: dashboard, logs, run history all built in
- Cloud or self-hosted: free OSS or hosted Prefect Cloud
- Mature: 6+ years in production, rich ecosystem
For Prefect’s official documentation, see docs.prefect.io.
Where Prefect does not lead
- For very high frequency tasks (1000+ tasks per second), Prefect adds latency
- For data-engineering-heavy workloads with many integrations, Dagster has more native connectors
- For purely scheduled cron jobs without orchestration, simpler tools (cron itself, GitHub Actions) suffice
For scraping, Prefect’s mid-frequency, retry-rich, observable model fits well.
For Dagster comparison, see building scraping pipelines with Dagster.
Installing Prefect 3
pip install prefect>=3.0 prefect --version # 3.xStart the local Prefect server:
prefect server start # Dashboard at http://localhost:4200For production, use Prefect Cloud (managed) or self-host the server with PostgreSQL.
A first scraping flow
# scraper_flow.py from prefect import flow, task import httpx @task(retries=3, retry_delay_seconds=10) async def fetch_url(url: str) -> dict: async with httpx.AsyncClient(timeout=30) as client: resp = await client.get(url, headers={ "User-Agent": "Mozilla/5.0 ...", }) resp.raise_for_status() return {"url": url, "status": resp.status_code, "html": resp.text} @task def parse_titles(html: str) -> list[str]: from selectolax.parser import HTMLParser tree = HTMLParser(html) return [n.text(strip=True) for n in tree.css("h2.title")] @flow(log_prints=True) async def scrape_titles(urls: list[str]): for url in urls: result = await fetch_url(url) titles = parse_titles(result["html"]) print(f"{url}: {len(titles)} titles") return "Done" if __name__ == "__main__": import asyncio asyncio.run(scrape_titles([ "https://example.com/page1", "https://example.com/page2", ]))Run it:
python scraper_flow.pyThe flow appears in the Prefect dashboard with full task history, retries, and logs.
Concurrency: parallel task execution
For parallel scraping, use Prefect’s task mapping or asyncio.gather:
from prefect import flow, task import httpx import asyncio @task(retries=3, retry_delay_seconds=10) async def fetch_url(url: str) -> dict: async with httpx.AsyncClient(timeout=30) as client: resp = await client.get(url) return {"url": url, "status": resp.status_code, "html": resp.text} @flow(log_prints=True) async def scrape_parallel(urls: list[str], concurrency: int = 10): semaphore = asyncio.Semaphore(concurrency) async def fetch_with_semaphore(url): async with semaphore: return await fetch_url(url) results = await asyncio.gather( *[fetch_with_semaphore(url) for url in urls], return_exceptions=True, ) return resultsOr using Prefect’s
.map()for static parallelism:@flow def scrape_parallel_static(urls: list[str]): results = fetch_url.map(urls) return resultsfetch_url.map(urls)schedules one task per URL and runs them via Prefect’s task runner. The defaultConcurrentTaskRunnerruns tasks in threads or processes; for async tasks use theThreadPoolTaskRunner.Work pools and workers
Prefect 3 splits the runtime model into:
- Flows / deployments: the work to run
- Work pools: configuration for runtime environments (Docker, Kubernetes, process)
- Workers: processes that pick up flow runs from work pools
This decouples scheduling from execution. You can have one Prefect server scheduling flow runs, with workers in different environments (laptop, Kubernetes cluster, EC2) picking up runs from the same work pool.
Create a work pool:
prefect work-pool create my-scraper-pool --type process prefect worker start --pool my-scraper-poolDeploy a flow to the pool:
from prefect import flow, serve @flow async def scrape_titles(urls: list[str]): # ... pass if __name__ == "__main__": scrape_titles.serve( name="scraper-deployment", work_pool_name="my-scraper-pool", cron="0 * * * *", # hourly )Now the flow runs hourly on whatever worker picks it up.
Retries and error handling
Per-task retry config is the most common pattern:
@task( retries=5, retry_delay_seconds=[10, 30, 60, 120, 300], # exponential backoff ) async def fetch_url(url: str) -> dict: async with httpx.AsyncClient(timeout=30) as client: resp = await client.get(url) if resp.status_code >= 500: raise httpx.HTTPStatusError( f"Server error: {resp.status_code}", request=resp.request, response=resp, ) if resp.status_code == 429: # Wait longer for rate limits await asyncio.sleep(60) raise httpx.HTTPStatusError("Rate limited", request=resp.request, response=resp) return {"url": url, "status": resp.status_code, "html": resp.text}For fine-grained retry policies (only retry on certain exceptions, not others):
from prefect.tasks import task_input_hash @task( retries=5, retry_delay_seconds=30, retry_condition_fn=lambda task, run_state: ( run_state.is_failed() and "5" in str(run_state.message) ), ) async def fetch_url(url: str) -> dict: # ... passThis retries only on 5xx errors, not on 4xx (which usually means permanent failure).
Persistent state: caching task results
For tasks that should not re-run on the same input (deduplication), use Prefect’s caching:
from prefect.tasks import task_input_hash from datetime import timedelta @task( cache_key_fn=task_input_hash, cache_expiration=timedelta(hours=24), ) async def fetch_url(url: str) -> dict: # ... passNow if you call
fetch_url("https://example.com")twice within 24 hours, the second call returns the cached result without running. Useful when re-running flows during development.Browser scraping with Playwright in Prefect
For pages requiring JavaScript:
from prefect import flow, task from playwright.async_api import async_playwright @task async def fetch_with_browser(url: str) -> dict: async with async_playwright() as p: browser = await p.chromium.launch(headless=True) page = await browser.new_page() await page.goto(url, wait_until="networkidle") html = await page.content() await browser.close() return {"url": url, "html": html} @flow async def scrape_dynamic_pages(urls: list[str]): results = [] for url in urls: results.append(await fetch_with_browser(url)) return resultsEach task call spawns a browser, which is expensive. For high-volume browser scraping, batch URLs per browser:
@task async def fetch_batch_with_browser(urls: list[str]) -> list[dict]: results = [] async with async_playwright() as p: browser = await p.chromium.launch(headless=True) for url in urls: page = await browser.new_page() try: await page.goto(url, timeout=30000) html = await page.content() results.append({"url": url, "html": html}) except Exception as e: results.append({"url": url, "error": str(e)}) finally: await page.close() await browser.close() return resultsThis amortizes browser startup over many URLs.
Scheduling
Prefect supports cron, interval, and event-based scheduling:
# Cron scrape_flow.serve( name="hourly-scraper", cron="0 * * * *", work_pool_name="my-pool", ) # Interval scrape_flow.serve( name="every-15min", interval=timedelta(minutes=15), work_pool_name="my-pool", ) # Event-based (triggered by webhooks, file events, etc.) from prefect.events import DeploymentEventTrigger scrape_flow.serve( name="on-event", triggers=[ DeploymentEventTrigger( expect={"my.custom.event"}, parameters={"urls": "{{ event.payload.urls }}"}, ) ], )Event-based scheduling is useful for “scrape this URL when a webhook fires” patterns.
A complete production example
End-to-end scraping pipeline:
# pipeline.py from prefect import flow, task, get_run_logger from prefect.tasks import task_input_hash from datetime import timedelta import asyncio import httpx from selectolax.parser import HTMLParser import asyncpg import json DB_DSN = "postgresql://user:pass@localhost/scraper" @task(retries=3, retry_delay_seconds=[10, 30, 60]) async def fetch_url(url: str) -> dict: async with httpx.AsyncClient(timeout=30) as client: resp = await client.get(url, headers={ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ...", }) resp.raise_for_status() return {"url": url, "status": resp.status_code, "html": resp.text} @task def parse_products(html: str) -> list[dict]: tree = HTMLParser(html) products = [] for el in tree.css("article.product"): products.append({ "title": el.css_first("h2").text(strip=True) if el.css_first("h2") else None, "price": el.css_first(".price").text(strip=True) if el.css_first(".price") else None, "url": el.css_first("a").attributes.get("href") if el.css_first("a") else None, }) return products @task(retries=3) async def store_products(products: list[dict]): logger = get_run_logger() if not products: return conn = await asyncpg.connect(DB_DSN) try: await conn.executemany( """INSERT INTO products (title, price, url) VALUES ($1, $2, $3) ON CONFLICT (url) DO UPDATE SET title = EXCLUDED.title, price = EXCLUDED.price""", [(p["title"], p["price"], p["url"]) for p in products], ) logger.info(f"Stored {len(products)} products") finally: await conn.close() @flow(log_prints=True) async def scrape_pipeline(urls: list[str], concurrency: int = 5): semaphore = asyncio.Semaphore(concurrency) async def process_one(url: str): async with semaphore: try: result = await fetch_url(url) products = parse_products(result["html"]) await store_products(products) return {"url": url, "ok": True, "count": len(products)} except Exception as e: return {"url": url, "ok": False, "error": str(e)} results = await asyncio.gather(*[process_one(url) for url in urls]) success = [r for r in results if r["ok"]] failed = [r for r in results if not r["ok"]] print(f"Scraped {len(success)} successfully, {len(failed)} failed") return {"success": len(success), "failed": len(failed)} if __name__ == "__main__": URLS = [f"https://example.com/products?page={i}" for i in range(1, 21)] asyncio.run(scrape_pipeline(URLS))Deploy with cron:
if __name__ == "__main__": URLS = [f"https://example.com/products?page={i}" for i in range(1, 21)] scrape_pipeline.serve( name="hourly-products", cron="0 * * * *", parameters={"urls": URLS, "concurrency": 5}, work_pool_name="my-pool", )Comparison: Prefect vs other orchestrators
feature Prefect 3 Dagster Airflow 2.x Python-native yes yes DAG syntax Async support excellent good poor Setup complexity low medium high Observability great dashboard great dashboard OK dashboard Hosted option Prefect Cloud Dagster Cloud many vendors Maturity very good very good excellent Best for mid-complexity workflows data engineering enterprise complexity For scraping pipelines specifically, Prefect’s async support and simpler API give it an edge. Dagster’s data-engineering features (asset-based, type system, integrations) are overkill for pure scraping but useful when you mix scraping with downstream ETL.
Deployment patterns
Production Prefect 3 deployments:
- Prefect Cloud + workers on EC2/GCE: hosted control plane, your compute
- Self-hosted Prefect server + Kubernetes workers: full self-host
- Prefect Cloud + serverless workers (ECS, Lambda): pay-per-use compute
- All-in-one VM: server + worker on same machine for small teams
For containerized workers:
FROM python:3.12-slim RUN pip install prefect playwright httpx selectolax asyncpg RUN playwright install chromium COPY . /app WORKDIR /app CMD ["prefect", "worker", "start", "--pool", "my-scraper-pool"]Run multiple worker containers behind the same pool for horizontal scaling.
Operational checklist
For production Prefect 3 scraping in 2026:
- Prefect 3.x with Python 3.11+
- Prefect Cloud or self-hosted server with PostgreSQL
- Work pools for environment isolation
- Multiple workers for horizontal scale
- Configure retries with exponential backoff
- Use task caching for development iteration
- Async tasks for network-bound work
- Browser scraping in batches per task
- External storage (Postgres, S3) for results
- Monitor flow run success rate, task retry counts
- Alert on flows that have not completed within SLA
For broader pipeline patterns, see building scraping pipelines with Dagster and distributed scraping with Apache Kafka.
Common pitfalls
- Sync code in async flows: blocks the event loop. Use async libraries throughout or wrap sync code in
asyncio.to_thread. - Too many tasks per flow: 1000+ tasks creates dashboard noise and overhead. Batch URLs into chunks per task.
- Browser launch per task: expensive. Batch URLs through one browser instance.
- Long-running tasks: Prefect’s task heartbeat timeout is configurable but default is short. For tasks running >5 minutes, set explicit timeout.
- Not using work pools: running flows directly works locally but does not scale. Move to work pools early.
FAQ
Q: do I need Prefect Cloud or can I self-host?
You can self-host the server. Prefect Cloud adds managed control plane, alerting, and UI hosting, but the OSS server has the full feature set. For most teams under 10 users, self-hosting is fine.Q: how does Prefect 3 differ from Prefect 2?
Faster task execution, simpler async, work pools (replaces work queues + agents from v2), better dashboard. The migration from v2 to v3 is straightforward for typical flows.Q: can I use Prefect with Scrapy?
Yes. Wrap Scrapy spider invocations as Prefect tasks. The orchestration value is in scheduling, retries, and downstream processing. See our Scrapy + Playwright integration writeup for Scrapy-specific patterns.Q: is Prefect overkill for a single scraper?
Yes. For a single cron-scheduled scraper, just use cron. Prefect pays off when you have multiple scrapers, retries, dependencies between flows, and want observability across all of them.Q: how does cost compare?
Self-hosted: just compute cost, $50-200/month for a small team. Prefect Cloud: usage-based, starts at $0 (free tier), grows with task runs. For most teams under 100k task runs/month, Prefect Cloud is cheaper than DIY observability tooling.Common pitfalls in production Prefect 3 scraping
The first failure mode is the Postgres backend overload from high-frequency task runs. Prefect 3 stores every task run state transition in its backend Postgres database. A scraping flow that processes 10,000 URLs as 10,000 individual tasks generates roughly 60,000 state-transition rows per flow run (PENDING, RUNNING, COMPLETED, plus retries). Daily flows produce 1.8M rows per month, and the backend’s
task_runandtask_run_statetables balloon to tens of GB within a quarter. Queries slow down, the UI stalls, and the worker heartbeat misses cause spurious flow failures. The fix is twofold: enablePREFECT_API_DATABASE_TIMEOUT=30to fail fast on slow queries rather than hanging, and run a weekly cleanup job that deletes flow run records older than 30 days:from prefect import flow from prefect.client.orchestration import get_client from datetime import datetime, timedelta @flow async def cleanup_old_runs(): cutoff = datetime.utcnow() - timedelta(days=30) async with get_client() as client: flow_runs = await client.read_flow_runs( flow_run_filter={"end_time": {"before_": cutoff}}, limit=1000, ) for fr in flow_runs: await client.delete_flow_run(fr.id)Schedule this nightly. Without it, expect to manually
TRUNCATEthe task_run table every few months once it crosses 50GB.The second pitfall is async semaphore leakage across task retries. Prefect’s
@task(retries=3)decorator retries a failed task by re-running it as a new attempt within the same flow context. If your task uses anasyncio.Semaphoreto bound concurrency, the semaphore is acquired in attempt 1, the task fails, attempt 2 runs but the semaphore was never released because the failure path skipped the release. After three failed attempts on five tasks, your concurrency limit is permanently reduced by 15 slots. The fix is to usetry/finallyaround every semaphore acquire, or better, useasync with semaphore:which guarantees release even on exception:async def process_one(url: str): async with semaphore: # guaranteed release result = await fetch_url(url) return parse(result)The third pitfall is the work pool concurrency limit being misinterpreted. The
concurrency_limiton a work pool caps the number of flow runs executing simultaneously, not the number of tasks within those flows. A work pool withconcurrency_limit=5and a flow that runs 100 tasks in parallel will happily run 5 flows × 100 tasks = 500 simultaneous tasks. If your worker box has 8 CPUs and 16GB RAM, this oversubscription causes thrashing. Set both flow-level and task-level concurrency: cap the work pool atconcurrency_limit=NAND cap async-gather inside each flow with a semaphore at a value that keeps total simultaneous tasks within hardware capacity.Real-world example: 200-site daily scraping pipeline
A scraping team built a Prefect 3 pipeline that scraped 200 ecommerce sites daily, processing roughly 4 million product pages per day. The architecture used three flow types: a discovery flow that enumerated category URLs per site, a scrape flow that fetched and parsed product pages, and a load flow that wrote results to the data warehouse:
from prefect import flow, task from prefect.tasks import task_input_hash from datetime import timedelta import asyncio import httpx @task(retries=2, retry_delay_seconds=30) async def discover_category_urls(site_config: dict) -> list[str]: async with httpx.AsyncClient(timeout=20) as client: resp = await client.get(site_config["sitemap_url"]) resp.raise_for_status() return parse_sitemap_for_categories(resp.text, site_config["url_pattern"]) @task(retries=3, retry_delay_seconds=[10, 30, 60]) async def scrape_product_batch(urls: list[str], site_config: dict) -> list[dict]: semaphore = asyncio.Semaphore(site_config.get("concurrency", 5)) async def fetch_one(url): async with semaphore: try: async with httpx.AsyncClient(timeout=15) as client: resp = await client.get(url, headers=site_config["headers"]) if resp.status_code == 200: return parse_product(resp.text, site_config) except Exception: return None results = await asyncio.gather(*[fetch_one(u) for u in urls]) return [r for r in results if r] @flow(name="site-scrape", log_prints=True) async def scrape_one_site(site_config: dict): category_urls = await discover_category_urls(site_config) # Batch into chunks of 50 URLs per task batches = [category_urls[i:i+50] for i in range(0, len(category_urls), 50)] batch_results = await asyncio.gather( *[scrape_product_batch(b, site_config) for b in batches] ) all_products = [p for batch in batch_results for p in batch] print(f"site={site_config['name']} products={len(all_products)}") await load_to_warehouse(all_products, site_config["name"]) @flow(name="all-sites-daily") async def scrape_all_sites(): site_configs = await load_site_configs() # Run sites in parallel but cap at 20 simultaneous sites semaphore = asyncio.Semaphore(20) async def run_site(cfg): async with semaphore: return await scrape_one_site(cfg) await asyncio.gather(*[run_site(cfg) for cfg in site_configs])Operational metrics over six months in production:
- Average daily flow duration: 4.2 hours
- p99 task duration: 18 seconds
- Task retry rate: 3.1 percent (mostly network blips)
- Daily backend Postgres growth: 2.8 GB
- Worker count: 4 (8 vCPU, 16GB each)
- Backend Postgres: db.r6g.xlarge ($420/month)
- Total Prefect-side infrastructure cost: $1,680/month
The team initially ran the discovery and scrape flows together but split them into separate flows after observing that discovery failures (sitemap parsing errors) blocked entire site processing. With separate flows, a discovery failure leaves yesterday’s category URLs as the working set for scraping, gracefully degrading rather than blocking. The lesson: orchestrator design choices about flow boundaries directly affect failure isolation.
Comparison: Prefect 3 task patterns by use case
A reference table for choosing the right Prefect 3 pattern per scraping scenario:
use case task pattern concurrency retry strategy sitemap discovery single task per site 20 sites in parallel 2 retries, 30s delay product scraping batch task (50 URLs each) 5 batches per site 3 retries, exponential backoff browser scraping batch task (10 URLs per browser) 3 browsers per site 2 retries, 60s delay API enrichment single task per record 50 records in parallel 5 retries, jittered exponential warehouse load single task per batch (1000 rows) 1 sequential per site 5 retries, 5min delay email notification terminal task 1 no retry screenshot capture batch task (5 URLs per browser) 2 per site 1 retry schema validation sync task with to_threadinline no retry For browser-heavy work, prefer fewer batches with more URLs per batch to amortize browser launch cost. For HTTP-heavy work, prefer smaller batches with more parallelism to keep latency low.
Wrapping up
Prefect 3 hits the right balance for scraping pipelines: enough orchestration for retries, scheduling, and observability without the heavy ceremony of Airflow. The async support is a real differentiator for I/O-bound scraping. Pair this with our building scraping pipelines with Dagster and Scrapy + Playwright integration writeups for the full pipeline picture, and browse the dev-tools-projects category on DRT for related infrastructure deep-dives.
-
Scrapy Cloud vs Crawlee Cloud in 2026
Scrapy Cloud vs Crawlee Cloud in 2026
Scrapy Cloud vs Crawlee Cloud is the comparison every Python or Node scraping team faces eventually. Both platforms come from Apify (which acquired Scrapinghub’s Scrapy Cloud business in 2021 and integrated Crawlee, its own Node-first scraping framework, into the same hosted offering). They share underlying infrastructure but differ in framework, language ecosystem, pricing model, and the tooling they expose. Picking the right one is mostly about which framework your team standardizes on and which language has better library coverage for your targets.
This guide compares both platforms feature by feature in 2026, prices the realistic cost at common workloads, and walks through deployment for each. The benchmarks come from actually running production scrapers on both during 2025-2026 with similar workloads. By the end you will know which platform fits your project, what the real costs are, and how to deploy without surprises.
What each platform is
Scrapy Cloud is Apify’s hosted platform for Scrapy spiders. You write Scrapy in Python, use
shub deployto push to the platform, and pay for compute units (run-time) and data items processed. Has a long history (since 2010), used by teams at large enterprises, and integrates with the Scrapy ecosystem (item pipelines, middleware, schedulers).Crawlee Cloud is Apify’s hosted platform for Crawlee, the JavaScript/TypeScript-first scraping framework that Apify itself maintains. You write Crawlee in Node or TypeScript, deploy via the Apify CLI, and pay for actor runs (compute time) and storage. Newer (Crawlee released 2022, hosted version stabilized 2024) but actively developed.
Both run on the same Apify cloud underneath, so deployment, scheduling, monitoring, and storage primitives are similar. The main difference is the framework you write in.
For Apify’s official platform docs, see docs.apify.com.
Pricing comparison
As of mid-2026:
dimension Scrapy Cloud Crawlee Cloud compute cost $0.40/CU/hr $0.40/CU/hr (same) storage cost $0.20/GB/month $0.20/GB/month (same) dataset reads $0.02/1k records $0.02/1k records (same) starter plan $39/month (1 CU) $39/month (1 CU) free tier $5 credit/month $5 credit/month Pricing is identical because they share infrastructure. The cost differences come from runtime efficiency, which depends on your code:
workload Scrapy time Crawlee time cost diff 100k HTML pages, simple parse 8 hr 6 hr Crawlee 25% cheaper 100k pages with Playwright 12 hr 10 hr Crawlee 17% cheaper 10k pages with custom middleware 0.5 hr 0.6 hr Scrapy slightly cheaper 1M pages, no JS 80 hr 60 hr Crawlee 25% cheaper Crawlee tends to run faster for browser-heavy workloads because its Playwright integration is more efficient. Scrapy can be faster for HTML-only workloads with heavy custom middleware where its async model shines.
For a 1M-page/month workload, expect ~$80-120/month on either platform.
Framework comparison
dimension Scrapy Crawlee primary language Python TypeScript / JavaScript years in production since 2008 since 2022 async model Twisted (legacy), asyncio (modern) Node async/await native browser integration scrapy-playwright built-in PlaywrightCrawler/PuppeteerCrawler middleware system very rich, mature growing deduplication Request fingerprinter RequestQueue with built-in dedup pipelines item pipelines (post-process) dataset push (simpler) spider lifecycle hooks yes (signals) yes (lifecycle handlers) stats collection Scrapy Stats built-in metrics community size very large growing TypeScript support n/a native Scrapy is the more mature framework with a richer middleware ecosystem. Crawlee is newer but designed from scratch for browser scraping, which gives it cleaner APIs for that use case.
For a Scrapy + Playwright deep dive, see Scrapy + Playwright integration in 2026.
Deploy: Scrapy Cloud
pip install shub shub login # paste API key from Apify console # In your Scrapy project shub deploy # Select project from listWait a minute for the build, then run via the web UI or CLI:
shub schedule scraper-name shub items <run-id> # download items as JSONscrapinghub.ymlconfig:projects: default: 12345 stacks: default: scrapy:2.11-py310 requirements: file: requirements.txtScrapy Cloud has a few stacks (Python versions and Scrapy versions). Pick the latest unless you have specific constraints.
Deploy: Crawlee Cloud
npm install -g apify-cli apify login # paste API key # In your Crawlee project apify pushThe Apify CLI wraps the project as an “actor” (Apify’s term for a deployable scraping unit) and pushes it to the platform.
actor.jsonconfig:{ "actorSpecification": 1, "name": "my-crawler", "version": "0.0", "buildTag": "latest", "input": { "title": "Crawler Input", "type": "object", "properties": { "startUrls": { "title": "Start URLs", "type": "array", "editor": "requestListSources" } } } }Run via the web UI or CLI:
apify call my-crawler --input='{"startUrls":["https://example.com"]}'Scrapy Cloud workflow
A typical Scrapy Cloud project:
# myspider.py import scrapy class MySpider(scrapy.Spider): name = "products" start_urls = ["https://example.com/products"] custom_settings = { "DOWNLOAD_DELAY": 1, "CONCURRENT_REQUESTS": 16, } def parse(self, response): for product in response.css("article.product"): yield { "title": product.css("h2::text").get(), "price": product.css(".price::text").get(), "url": product.css("a::attr(href)").get(), } next_page = response.css("a.next::attr(href)").get() if next_page: yield response.follow(next_page, self.parse)scrapy_cloud.yml:project: 12345 stack: scrapy:2.11-py310 requirements: file: requirements.txtThe platform handles scheduling, logs, and item storage. Items are written to Scrapy Cloud’s Items API, which you can pull via REST or download as CSV/JSON.
Crawlee Cloud workflow
// src/main.ts import { CheerioCrawler, Dataset } from "crawlee"; const crawler = new CheerioCrawler({ async requestHandler({ request, $, log, enqueueLinks }) { log.info(`Processing ${request.url}`); const products = $("article.product").map((_, el) => ({ title: $(el).find("h2").text().trim(), price: $(el).find(".price").text().trim(), url: $(el).find("a").attr("href"), })).get(); await Dataset.pushData(products); await enqueueLinks({ selector: "a.next", label: "PAGINATION", }); }, maxRequestsPerCrawl: 1000, maxConcurrency: 10, }); await crawler.run(["https://example.com/products"]);Crawlee’s
Datasetis the cloud-side equivalent of Scrapy items.enqueueLinksis the simpler equivalent ofresponse.follow.Browser support
feature Scrapy Cloud Crawlee Cloud Playwright via scrapy-playwright native PlaywrightCrawler Puppeteer not supported native PuppeteerCrawler Headless Chrome yes yes Headless Firefox yes yes Mobile emulation manual config built-in helpers Browser pool / context reuse manual built-in Stealth (patchright integration) manual via custom middleware recipe in docs Crawlee has more out-of-the-box ergonomics for browser scraping. Scrapy gets you there but with more configuration.
Storage
Both platforms expose:
- Datasets: structured data from your scrapers (rows of items)
- Key-value stores: arbitrary blobs (HTML snapshots, screenshots, JSON configs)
- Request queues: URL queues for crawl coordination
Pricing is the same. APIs are very similar. For most purposes the storage layer is interchangeable.
# Scrapy: writing to a dataset yield {"title": title, "price": price} # Crawlee: writing to a dataset await Dataset.pushData({title, price});# Scrapy: reading items from a previous run via API import requests resp = requests.get( f"https://api.apify.com/v2/datasets/{dataset_id}/items", headers={"Authorization": f"Bearer {API_KEY}"}, )// Crawlee: reading items via SDK import { ApifyClient } from "apify-client"; const client = new ApifyClient({ token: API_KEY }); const items = await client.dataset(datasetId).listItems();Scheduling
Both support cron-style scheduling via the Apify console or API.
Scrapy Cloud:
shub schedule myspider --frequency 'every 1 hour'Crawlee Cloud / Apify:
// Via Apify SDK import { ApifyClient } from "apify-client"; const client = new ApifyClient({ token: API_KEY }); await client.actor("my-actor").schedules.create({ cronExpression: "0 * * * *", // hourly timezone: "UTC", });For more complex orchestration (DAGs, dependencies between scrapers), use external schedulers like Prefect or Dagster. See building scraping pipelines with Prefect 3 and building scraping pipelines with Dagster.
Proxy support
Both platforms include Apify Proxy:
- Datacenter proxies: included in plans, per-GB billed beyond free tier
- Residential proxies: $8-12/GB
- Smart Proxy: rotating residential with automatic anti-bot evasion
Per-request proxy assignment in Scrapy:
import os custom_settings = { "DOWNLOAD_DELAY": 1, "DOWNLOADER_MIDDLEWARES": { "scrapinghub_proxy.ScrapinghubProxyMiddleware": 410, }, "PROXY_GROUPS": ["RESIDENTIAL"], }In Crawlee:
import { CheerioCrawler, ProxyConfiguration } from "crawlee"; const proxyConfiguration = new ProxyConfiguration({ groups: ["RESIDENTIAL"], }); const crawler = new CheerioCrawler({ proxyConfiguration, // ... });Apify Proxy is convenient but more expensive than dedicated providers. For high volume, route through Bright Data, Oxylabs, or self-hosted instead. See best residential proxy providers 2026.
When to choose Scrapy Cloud
Pick Scrapy Cloud when:
- Your team is Python-first and already uses Scrapy
- You have existing Scrapy spiders to migrate
- You need rich middleware (cookies, retries, custom headers per host)
- You want item pipelines for post-processing (validation, dedup, DB write)
- Long-running spiders that benefit from Twisted/asyncio-style concurrency
- Heavy HTML parsing where Scrapy’s async model is faster
When to choose Crawlee Cloud
Pick Crawlee Cloud when:
- Your team is JavaScript/TypeScript-first
- Your scraping is browser-heavy (Crawlee’s Playwright integration is cleaner)
- You want simpler APIs (less to learn than Scrapy)
- You want native TypeScript support
- You operate Apify actors for other use cases already
When to use neither
Pick self-hosted when:
- You have ops capacity and want to avoid platform lock-in
- Volume is high enough that hosted costs dominate compute (5M+ pages/month)
- You need custom infrastructure (specific GPU, specific OS, edge deployment)
- You want full control of proxy egress
For self-hosted patterns, see self-hosted proxy infrastructure and building scraping pipelines with Prefect 3.
Comparison: hosted vs self-hosted
dimension hosted (Apify) self-hosted ops burden none high time to first run minutes days cost at low volume low high (fixed VM cost) cost at high volume medium low scaling automatic manual compliance Apify’s audits yours customization limited unlimited break-even ~5M pages/month For most teams under 5M pages/month, hosted is cheaper and faster. Above that, the calculation shifts toward self-hosted.
Migration: Scrapy → Crawlee or vice versa
If you decide to switch frameworks, the migration involves rewriting spiders. Scrapy items map to Crawlee dataset records, Scrapy middleware maps to Crawlee request handlers, but the syntax is entirely different. Plan a 2-4 week project for a moderate-sized spider suite.
Common migration concerns:
- Custom middleware: rewrite as Crawlee request hooks or pre-navigation hooks
- Item pipelines: move post-processing into the request handler or into a downstream Apify actor
- Custom selectors: cheerio (Crawlee) is similar to BeautifulSoup but not identical
- Stats and monitoring: Apify SDK provides equivalent metrics
Operational checklist
For deciding between platforms in 2026:
- Match framework to team language (Python → Scrapy, Node → Crawlee)
- Estimate monthly compute cost based on test runs
- Verify proxy bandwidth budget separately
- Test deployment workflow with a real spider
- Set up scheduling for your most frequent jobs
- Use external storage (S3, Postgres) for long-term data, not Apify datasets
- Monitor compute unit consumption weekly
- Plan migration to self-hosted at the volume break-even
FAQ
Q: can I run both Scrapy and Crawlee on the same Apify account?
Yes. They are different actor types but share the same compute pool, storage, and billing.Q: which is faster for the same workload?
Crawlee tends to be 15-25% faster for browser-heavy workloads, Scrapy can be faster for pure HTML scraping with heavy middleware. Both are within the same cost ballpark.Q: does Crawlee support Python?
A Python port of Crawlee exists (“crawlee-python”) but is less mature than the TypeScript original. For Python in 2026, Scrapy is still the more mature choice.Q: can I use Apify Proxy with my own scraper code?
Yes. Apify Proxy is exposed as standard HTTP/HTTPS endpoints with credentials. Any scraper that supports HTTP proxies can use it.Q: what about open-source self-hosted Apify (Apify Open Source)?
Apify open-sourced parts of the platform around 2024. You can run a limited subset on your own infrastructure but not the full hosted experience. For most teams, the hosted offering is more practical until volume justifies the build.Common pitfalls in production hosted scraping
The first failure mode that catches teams migrating from self-hosted is compute unit (CU) billing surprise. Apify’s CU pricing meters not just CPU time but also memory-time-product. A spider configured with 4GB RAM that runs for 10 minutes consumes more CU than a spider configured with 1GB RAM that runs for 30 minutes, even though wall-clock memory usage may be similar. The fix is to right-size memory allocation per spider via the
actor.jsonconfig. Default to 1GB and only bump if you observe OOM kills in the logs. The CU savings from halving memory often outweigh the marginal latency increase from tighter memory pressure.The second pitfall is dataset row limit drift on the free tier. Apify’s free tier caps datasets at 10,000 records per dataset, which scrapers commonly hit during development without realizing. The dataset.pushData() call returns success but the underlying storage rejects rows beyond the limit, leading to silent data loss. Test runs with 5K records succeed, production runs with 50K records lose 80 percent of rows. The fix is to either upgrade off the free tier (the Personal plan at $49/month removes the limit) or to chunk results into multiple datasets via
Dataset.open(name)with a rolling name like${date}-${shard}.The third pitfall is Apify Proxy IP exhaustion under per-host rate limits. Apify Proxy’s residential pool serves all customers from a shared IP set. If you hit a target site at 50 requests/second, Apify’s pool may rotate through 200 IPs in an hour, and the target’s per-IP rate limiter starts returning 429s on previously-fresh IPs because other Apify customers also hit them earlier in the day. The mitigation for high-volume targets is to use a dedicated residential pool (Apify offers it at higher per-GB cost) or to bypass Apify Proxy entirely and route through your own residential provider via the
proxyUrlfield on individual requests.Real-world example: cost analysis migration from Apify to self-hosted
A scraper team running 8 million product pages per month across 40 ecommerce sites tracked their costs on Apify Scrapy Cloud over six months and decided to migrate to self-hosted in month seven. The detailed cost breakdown that drove the decision:
month apify CU proxy GB apify total self-host equivalent jan 4,200 320 $2,840 $1,100 feb 4,800 380 $3,260 $1,100 mar 5,100 410 $3,510 $1,100 apr 5,400 450 $3,790 $1,150 may 6,200 520 $4,420 $1,200 jun 7,100 600 $5,080 $1,250 The self-host equivalent included: a 16-vCPU VM ($380/month), 50 residential IPs from a budget provider at $4/GB ($200-2400/month based on usage), Postgres for results ($120/month), Prometheus + Grafana monitoring ($80/month), and one full-time engineer maintaining the stack at 0.2 FTE (allocated cost $400/month).
Migration took 8 weeks and consumed $32,000 in engineering time. After migration, monthly cost stabilized at $1,250 versus the projected Apify cost of $5,800+ by month seven. Payback period: 7.5 months from migration completion. The team retained Apify Scrapy Cloud for two specific spiders that benefited from Apify’s anti-detection-tuned proxy pool (those spiders accounted for 8 percent of total volume), so they did not fully zero their Apify bill.
The lessons: Apify is meaningfully cheaper than self-hosted up to roughly 3-5 million pages/month. Above that, self-hosted wins decisively. Hybrid (most volume self-hosted, anti-bot-heavy targets on Apify) is a viable middle ground that captures both economies.
Comparison: Scrapy Cloud vs Crawlee Cloud feature parity matrix
A 2026-current feature comparison across the two platforms on Apify’s shared infrastructure:
feature Scrapy Cloud Crawlee Cloud max concurrent runs 32 (Personal), unlimited (Team+) 32 (Personal), unlimited (Team+) max RAM per actor 32GB 32GB storage retention 7 days (free), 30 days (paid) 7 days (free), 30 days (paid) scheduled runs yes yes webhook callbacks yes yes dataset format JSON, CSV, XML, Excel JSON, CSV, XML, Excel key-value store yes yes live console yes yes log retention 14 days 14 days proxy: datacenter included included proxy: residential $8-12/GB $8-12/GB browser support Playwright via scrapy-playwright native Playwright + Puppeteer TypeScript support no (Python only) yes (first-class) AI agent integration manual Apify Agents native (2025+) Webhooks-as-trigger yes yes Apify SDK access yes (Python) yes (Node/Bun) Container deploy yes (custom Dockerfile) yes (custom Dockerfile) The platforms are at near-feature-parity in 2026. The choice between them is almost entirely about your team’s language preference. The exception: Apify Agents (their AI agent framework launched 2025) is Node-first and integrates more cleanly with Crawlee. Teams building AI-driven scrapers tend to lean toward Crawlee for that integration.
Detection: when hosted is the wrong choice
Five signals that your scraping workload should NOT live on Apify:
- Custom TLS fingerprinting required: Apify’s HTTP egress uses their pool’s fingerprint; you cannot inject curl_cffi or tls-client at the network layer. Move to self-hosted.
- Hardware GPU for inference inside the spider: Apify does not provide GPU instances. Use Modal, RunPod, or self-hosted with GPUs.
- Compliance requires data residency: Apify operates in EU and US regions only. If your data must stay in Singapore, Brazil, or other regions, self-host or pick a regional provider.
- Real-time webhooks under 500ms p99: Apify actor cold starts can exceed 5 seconds. For real-time response APIs, run a long-lived service on Cloud Run or Fargate instead.
- Custom kernel-level networking: Apify runs in a managed container environment; you cannot install custom iptables rules, custom DNS, or kernel modules. Self-host on a VM if you need this.
If any two of these apply, the answer is self-hosted regardless of volume.
Wrapping up
Scrapy Cloud vs Crawlee Cloud is mostly a framework choice in 2026. Same underlying infrastructure, same pricing, similar features. Pick Scrapy Cloud if your team is Python-first; pick Crawlee Cloud if your team is Node-first. Both pay off at low to medium volumes versus self-hosting, then break even and lose to self-hosted around 5M+ pages/month. Pair this with our Scrapy + Playwright integration and self-hosted proxy infrastructure writeups for the full hosted-vs-self-hosted picture, and browse the dev-tools-projects category on DRT for related infrastructure deep-dives.
Related comparison: See how Bright Data stacks up against a dedicated Singapore mobile network in our Singapore Mobile Proxy vs Bright Data comparison.
-
Distributed scraping with Apache Kafka in 2026
Distributed scraping with Apache Kafka in 2026
Distributed scraping with Apache Kafka is what you reach for when a single Scrapy process or single-machine Playwright cluster cannot keep up with your URL volume. Kafka excels at moving high-volume work between producers (URL discovery, sitemap crawlers) and consumers (scraper workers) with durable messaging, partitioning for parallelism, and built-in retry semantics. By 2026 Kafka 3.x is mature enough that running it in production for scraping pipelines is standard practice, especially for teams already operating Kafka for other data flows.
This guide covers Kafka topic design for scraping pipelines, partition strategies that keep scrapers fed, dead letter queue patterns for failed URLs, exactly-once semantics for deduplicated processing, and a complete working Python implementation using confluent-kafka. The benchmarks reflect production deployments handling 10-50 million URLs per day. By the end you will know how to architect a Kafka-backed scraping pipeline that scales horizontally and survives operational chaos.
When Kafka fits scraping
Kafka shines when:
- You have multiple URL sources (sitemaps, APIs, customer uploads, periodic crawls) feeding scrapers
- Scraper workers run on different machines and you want to balance load
- You need to retry failed URLs without losing them
- You want to fan out the same URLs to multiple downstream processors (HTML store, ML extraction, analytics)
- You operate other Kafka pipelines and want consistency
- You need exactly-once processing semantics
Kafka does not fit when:
- You have low volume (under 1M URLs/day, simpler queues like Redis or SQS suffice)
- You operate one scraping job at a time (no need for the multi-producer/multi-consumer architecture)
- Your team has no Kafka operational experience
- You need request/response semantics (Kafka is one-way; for scraper status, use a separate sync API)
For most mid-volume scraping, simpler queues work. Kafka pays off above several million URLs per day or when fanout to multiple consumers is required.
For the official Kafka documentation, see kafka.apache.org/documentation/.
Architecture overview
A typical Kafka-backed scraping pipeline:
URL sources Scraper workers Storage / downstream +----------+ +-------------+ +-------------+ | sitemap | --+ | | --+ | postgres | | crawler | | | worker_1 | | +-------------+ +----------+ | | | | | +-------+ +-------------+ | +-------------+ +----------+ +->| urls |---->| | +------>| s3 / r2 | | API feed | +-->| topic | | worker_2 | | +-------------+ +----------+ | +-------+ | | | | +-------------+ | +-------------+ +----------+ | | | +------>| ML pipeline | | customer |--+ | worker_N | +-------------+ | upload | +-------------+ +----------+ retries: dlq topicEach producer pushes URLs to the
urlstopic. Multiple scraper workers consume from partitions of that topic in parallel. Successful results go to apagestopic that downstream consumers pick up. Failed URLs go to adlqtopic for retry or manual review.Topic design for scraping
The standard set of topics:
topic purpose partitions retention urls URLs to scrape 50 7 days pages scraped page content 20 14 days extractions parsed structured data 20 30 days errors scraper errors 5 7 days dlq failed URLs for retry 5 30 days metrics per-URL timing/status 10 1 day Partitioning matters because Kafka guarantees ordered processing only within a partition. For scraping, you usually want:
- By host:
partition = hash(url.host) % num_partitions. Keeps all URLs for one host on one partition, enables per-host rate limiting. - By customer:
partition = hash(customer_id) % num_partitions. Tenant isolation, billing per customer. - Random: any partition. Maximum parallelism, no host-level coordination.
For scraping with anti-bot vendors, by-host partitioning is the right default. It lets each consumer apply per-host throttling without coordinating with other consumers.
Setup: Kafka 3.x with KRaft
Kafka 3.5+ removed the ZooKeeper dependency. KRaft (Kafka Raft) is now the recommended setup. For local dev:
# docker-compose.yml version: "3.8" services: kafka: image: bitnami/kafka:3.7 ports: - "9092:9092" environment: KAFKA_CFG_NODE_ID: 1 KAFKA_CFG_PROCESS_ROLES: controller,broker KAFKA_CFG_CONTROLLER_QUORUM_VOTERS: 1@kafka:9093 KAFKA_CFG_LISTENERS: PLAINTEXT://:9092,CONTROLLER://:9093 KAFKA_CFG_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092 KAFKA_CFG_CONTROLLER_LISTENER_NAMES: CONTROLLER KAFKA_CFG_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXTFor production, use a managed service (Confluent Cloud, AWS MSK) or self-host on at least three nodes with proper replication.
Create the topics:
kafka-topics.sh --create --bootstrap-server localhost:9092 \ --topic urls --partitions 50 --replication-factor 1 \ --config retention.ms=604800000 kafka-topics.sh --create --bootstrap-server localhost:9092 \ --topic pages --partitions 20 --replication-factor 1 \ --config retention.ms=1209600000 kafka-topics.sh --create --bootstrap-server localhost:9092 \ --topic dlq --partitions 5 --replication-factor 1 \ --config retention.ms=2592000000In production, use replication-factor 3 for durability.
Producer: feeding URLs
A simple Python producer using confluent-kafka:
# producer.py from confluent_kafka import Producer import json import hashlib producer = Producer({ "bootstrap.servers": "kafka:9092", "client.id": "url-discovery", "acks": "all", "compression.type": "lz4", "retries": 5, "linger.ms": 10, }) def url_key(url: str) -> bytes: """Partition by host so per-host throttling works in consumers.""" from urllib.parse import urlparse host = urlparse(url).hostname or "" return host.encode() def produce_url(url: str, customer_id: str = "default", priority: int = 0): payload = { "url": url, "customer_id": customer_id, "priority": priority, "discovered_at": int(time.time()), } producer.produce( topic="urls", key=url_key(url), value=json.dumps(payload).encode(), callback=lambda err, msg: ( print(f"Failed: {err}") if err else None ), ) if __name__ == "__main__": import sys, time with open(sys.argv[1]) as f: for line in f: url = line.strip() if url: produce_url(url) producer.flush(timeout=30)Key choices:
acks="all": wait for all in-sync replicas to acknowledge. Durable but slower.compression.type="lz4": roughly 60-80% size reduction with low CPU overhead.linger.ms=10: batch up to 10ms before sending. Reduces request count.key=hostname.encode(): partitions by host.
For very high throughput, use
acks=1(only leader) and accept slightly higher loss risk in exchange for 2-3x speed.Consumer: the scraper worker
A consumer that fetches URLs and produces results:
# scraper_worker.py from confluent_kafka import Consumer, Producer, KafkaError import json import requests import time import logging from collections import defaultdict logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) consumer = Consumer({ "bootstrap.servers": "kafka:9092", "group.id": "scrapers", "auto.offset.reset": "earliest", "enable.auto.commit": False, "max.poll.interval.ms": 600000, # 10 min "session.timeout.ms": 30000, }) consumer.subscribe(["urls"]) producer = Producer({ "bootstrap.servers": "kafka:9092", "compression.type": "lz4", "acks": "all", }) # Per-host last-fetch timestamps for throttling last_fetch = defaultdict(float) HOST_DELAY = 1.5 # seconds between same-host requests def scrape(url: str) -> dict: from urllib.parse import urlparse host = urlparse(url).hostname # Per-host throttle elapsed = time.time() - last_fetch[host] if elapsed < HOST_DELAY: time.sleep(HOST_DELAY - elapsed) last_fetch[host] = time.time() resp = requests.get(url, timeout=30, headers={ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ...", }) return { "url": url, "status": resp.status_code, "html": resp.text if resp.status_code == 200 else None, "fetched_at": int(time.time()), } def send_to_dlq(message_value: bytes, error: str): producer.produce( topic="dlq", key=message_value, value=json.dumps({ "original": json.loads(message_value), "error": error, "failed_at": int(time.time()), }).encode(), ) while True: msg = consumer.poll(timeout=1.0) if msg is None: continue if msg.error(): if msg.error().code() == KafkaError._PARTITION_EOF: continue logger.error(f"Consumer error: {msg.error()}") continue payload = json.loads(msg.value()) url = payload["url"] try: result = scrape(url) if result["status"] == 200: producer.produce( topic="pages", key=url.encode(), value=json.dumps(result).encode(), ) elif result["status"] >= 500: send_to_dlq(msg.value(), f"HTTP {result['status']}") # else: 4xx errors are skipped (404, 403, etc.) consumer.commit(msg) # commit offset only on success or known-skip except Exception as e: logger.error(f"Failed to scrape {url}: {e}") send_to_dlq(msg.value(), str(e)) consumer.commit(msg) # still commit, DLQ has the messageKey choices:
enable.auto.commit=False: manual commit so we only advance after processing- Per-host throttle via
last_fetchdict - DLQ for transient errors (5xx, exceptions)
- Skip 4xx errors (404, 403 are usually permanent for that URL)
Per-host rate limiting at scale
The simple per-process throttle above breaks when you have multiple consumers per partition. By partitioning on host, each host’s URLs all land on one partition, which is consumed by one consumer at a time within a consumer group. So the per-process throttle effectively becomes a per-host throttle across the cluster.
For finer control (multiple consumers, different rate limits per customer), use a distributed rate limiter (Redis-backed):
import redis r = redis.Redis(host="redis", port=6379) def can_fetch_host(host: str, max_per_sec: float = 1.0) -> bool: """Token bucket via Redis.""" key = f"rate:{host}" now = time.time() pipe = r.pipeline() pipe.zremrangebyscore(key, 0, now - 1) # remove old entries pipe.zcard(key) pipe.zadd(key, {str(now): now}) pipe.expire(key, 10) _, count, _, _ = pipe.execute() return count < max_per_sec # In scraper: while not can_fetch_host(host): time.sleep(0.1)This gives global rate limiting independent of partition distribution.
Dead letter queue handling
DLQ patterns:
# dlq_consumer.py from confluent_kafka import Consumer, Producer import json import time consumer = Consumer({ "bootstrap.servers": "kafka:9092", "group.id": "dlq-retry", "auto.offset.reset": "earliest", }) consumer.subscribe(["dlq"]) producer = Producer({"bootstrap.servers": "kafka:9092"}) while True: msg = consumer.poll(timeout=1.0) if msg is None: continue if msg.error(): continue record = json.loads(msg.value()) failed_at = record["failed_at"] age = time.time() - failed_at # Retry after 1 hour cooldown if age < 3600: continue # Re-emit to urls topic producer.produce( topic="urls", key=record["original"]["url"].encode(), value=json.dumps(record["original"]).encode(), ) consumer.commit(msg)Or for permanent failures: a dashboard query of the DLQ topic for ops review.
Exactly-once semantics
Kafka 3.x supports exactly-once semantics (EOS) via transactional producers and
read_committedconsumers. For scraping, this matters when you want each URL processed exactly once across worker restarts and failures.producer = Producer({ "bootstrap.servers": "kafka:9092", "transactional.id": f"scraper-{worker_id}", "enable.idempotence": True, }) producer.init_transactions() # In scraper loop: producer.begin_transaction() producer.produce(topic="pages", value=json.dumps(result).encode()) producer.send_offsets_to_transaction( [TopicPartition("urls", partition, offset)], consumer_group_metadata ) producer.commit_transaction()This guarantees that if the scraper crashes mid-process, the offset commit and the result production happen atomically: either both, or neither. The next consumer instance picks up from the same offset and processes the URL again.
Comparison: Kafka vs alternatives for scraping queues
platform pros cons scale Kafka high throughput, durable, multi-consumer, EOS complex ops, requires JVM up to 10M+ msg/sec Redis Streams simple, fast, low overhead single-node, less durable up to 100k msg/sec RabbitMQ rich routing, mature lower throughput than Kafka up to 100k msg/sec SQS managed, cheap, infinite scale per-message cost, no replay up to millions/sec NATS JetStream simple, fast, durable smaller community up to 1M msg/sec Pulsar Kafka-alternative, geo-replication smaller adoption comparable to Kafka For scraping, Kafka and Pulsar both work; Kafka has the larger community. SQS is often the simpler choice if your AWS bill is comfortable with per-message charges. For under 1M URLs/day, Redis Streams is hard to beat for simplicity.
Monitoring
Critical Kafka metrics for scraping:
metric what it tells you consumer lag (urls topic) how far behind workers are produce rate (urls) URL discovery throughput consume rate (urls) scrape throughput DLQ size failure rate rebalance count worker churn under-replicated partitions broker issues Tools:
- Kafka Exporter for Prometheus: scrapes Kafka metrics
- Conduktor or Confluent Control Center: GUI for cluster management
- Burrow: consumer lag monitoring with alerting
- Strimzi: Kubernetes operator with built-in monitoring
Set alerts on consumer lag > 30 min, DLQ size growth, broker downtime.
Production deployment
For 10M URLs/day:
- 3-broker Kafka cluster on Kafka 3.7 with KRaft
- 50 partitions on
urlstopic - 20 scraper workers, each consuming from 2-3 partitions
- Per-host throttle via partition assignment + Redis backstop
- Postgres for results (loaded from
pagestopic by separate consumers) - DLQ retry consumer running every hour
- Prometheus + Grafana for monitoring
- ~$1500/month in compute + Kafka
For 100M URLs/day:
- 5+ broker cluster with replication-factor 3
- 200+ partitions
- 100+ scraper workers
- Multiple Kafka clusters segmented by region
- ~$15k/month compute + Kafka
Operational checklist
For Kafka-backed scraping in 2026:
- Kafka 3.5+ with KRaft (no ZooKeeper)
- Topic design: urls, pages, dlq, errors, metrics
- Partition by host for per-host coordination
- LZ4 compression on all topics
- acks=all for producers (durability)
- Manual commit on consumers (process before commit)
- Per-host throttling (partition-level + Redis backstop)
- DLQ retry consumer
- Prometheus + Grafana monitoring
- Alerts on lag, DLQ growth, broker health
- Schema registry if pipeline complexity grows
For broader pipeline patterns, see building scraping pipelines with Prefect 3 and building scraping pipelines with Dagster.
Common pitfalls
- Partition imbalance: bad partition keys cause hot partitions. Audit URL distribution by partition.
- Slow consumers and consumer group rebalances: long-running scrapes blow past
max.poll.interval.ms. Bump it or split work. - Offset commit before processing: leads to data loss. Always commit after successful processing.
- DLQ overflow: if 30% of URLs fail, DLQ fills faster than retries clear it. Tune retry policy or fix underlying issues.
- Memory pressure on consumers: large pages (>1 MB HTML) accumulate in producer buffer. Cap message size or stream pages directly to S3.
- Kafka cluster upgrade missteps: KRaft migration requires careful planning. Read the Kafka KRaft docs.
FAQ
Q: do I need Kafka for under 1M URLs/day?
Probably not. Redis Streams or SQS will be simpler and sufficient. Kafka pays off above 1-10M URLs/day where the durability, throughput, and multi-consumer fanout matter.Q: can I use Kafka for both URL queue and result storage?
Kafka is a queue, not a database. Use it for streaming and short-term retention. Store results in a database (Postgres, ClickHouse) or object storage (S3, R2) via downstream consumers.Q: how do I retry failed URLs without losing the original message?
DLQ topic. Failed processing produces to DLQ, original offset commits. A retry consumer reads DLQ on a schedule and re-emits to the urls topic.Q: what about Kafka Streams for in-flight processing?
Useful for derived data (aggregations, deduplication). For raw scraping, KafkaConsumer is enough. Add Streams when you need stateful enrichment.Q: does this work with Confluent Cloud?
Yes. The Python client is identical, only the bootstrap.servers and SASL credentials change. Confluent Cloud removes the ops burden at the cost of usage-based pricing.Real-world example: 80M URL crawl with KRaft and per-host fairness
A scraping team migrated from a single-broker Kafka cluster to a 5-broker KRaft cluster handling 80 million URLs per day across 12,000 distinct hosts. The bottleneck before migration was not throughput but fairness: a few hosts (Amazon, eBay, Walmart) made up 40 percent of all URLs and starved smaller hosts because their partitions had longer queue depth. The fix involved a custom partitioner that hashed by host AND by a “fairness band” derived from the host’s URL queue depth:
import hashlib from confluent_kafka import Producer class FairHostPartitioner: def __init__(self, num_partitions: int, queue_depth_provider): self.num_partitions = num_partitions self.queue_depth = queue_depth_provider # callable(host) -> int def partition(self, key: bytes, all_partitions: list) -> int: host = key.decode().split("/")[2] depth = self.queue_depth(host) # High-depth hosts get spread across multiple partitions if depth > 100_000: band = hashlib.sha256(key).hexdigest()[:8] band_int = int(band, 16) % 4 # spread across 4 partitions base = int(hashlib.sha256(host.encode()).hexdigest()[:8], 16) return (base + band_int) % self.num_partitions # Low-depth hosts get one partition (per-host throttling preserved) return int(hashlib.sha256(host.encode()).hexdigest()[:8], 16) % self.num_partitions producer = Producer({ "bootstrap.servers": "kafka-1:9092,kafka-2:9092,kafka-3:9092", "compression.type": "lz4", "linger.ms": 10, "batch.size": 65536, })After deployment, p99 URL-to-fetch latency for low-volume hosts dropped from 4.2 hours to 18 minutes. High-volume hosts saw their throughput increase 3.5x because the work was now spread across 4 partitions instead of bottlenecking on one. The 5-broker cluster ran at 65 percent CPU during steady state, leaving headroom for daily peak loads.
Common pitfalls in production Kafka scraping
The first failure mode is consumer group rebalance storms during scraper deploys. When you redeploy 20 scraper workers via rolling restart, each worker’s exit triggers a rebalance, which pauses all consumers for 5-30 seconds while partitions reassign. With 20 workers redeploying sequentially, you accumulate 100-600 seconds of pause time during the deploy window. The fix is cooperative rebalancing (Kafka 2.4+) which only reassigns the partitions of the leaving worker rather than all partitions:
consumer = Consumer({ "bootstrap.servers": "kafka:9092", "group.id": "scrapers", "partition.assignment.strategy": "cooperative-sticky", "session.timeout.ms": 30000, "max.poll.interval.ms": 600000, # 10 min for slow scrapes })After enabling cooperative-sticky, deploy-induced pause time drops by roughly 90 percent because each worker exit only pauses its own 2-3 partitions instead of all 50.
The second pitfall is the
__consumer_offsetstopic bloat. Kafka stores consumer group offsets in an internal compacted topic. Scrapers that commit offsets aggressively (every message instead of every batch) generate millions of offset commits per day, which can outgrow the broker’s compaction capacity and cause the topic to balloon to tens of GB. The fix is to commit in batches of 100-1000 messages rather than per-message:batch = [] for msg in consumer: process(msg) batch.append((msg.topic, msg.partition, msg.offset + 1)) if len(batch) >= 100: consumer.commit(offsets=batch) batch = []The third pitfall is the producer buffer exhaustion under back-pressure. When a broker becomes slow or unreachable, the producer’s
buffer.memory(default 32MB) fills up with un-acked messages, andproducer.send()calls block indefinitely once full. Scrapers that produce results while consuming URLs can deadlock: the producer blocks on a slow broker, the consumer can’t make progress because it can’t produce results, the consumer gets kicked from the group for missed heartbeats, and the entire pipeline halts. Configuredelivery.timeout.msandrequest.timeout.msaggressively, and useproducer.flush(timeout=10)with a timeout rather than indefinite blocking:producer = Producer({ "bootstrap.servers": "kafka:9092", "delivery.timeout.ms": 30000, "request.timeout.ms": 15000, "buffer.memory": 67108864, # 64MB "max.block.ms": 5000, # don't block produce() longer than 5s })If max.block.ms is exceeded, producer.produce() raises BufferError, which you handle by dropping the message to a local fallback file rather than crashing the consumer.
Wrapping up
Distributed scraping with Kafka pays off when volume justifies the operational complexity. For 10M+ URLs/day, the durability, partitioning, and multi-consumer architecture are hard to beat. For under 1M, simpler queues are usually the right answer. Pair this with our building scraping pipelines with Prefect 3 and Scrapy Cloud vs Crawlee Cloud writeups for the full pipeline picture, and browse the dev-tools-projects category on DRT for related infrastructure deep-dives.
-
Deno scraping libraries 2026 reviewed
Deno scraping libraries 2026 reviewed
Deno scraping libraries reached a stability tipping point in 2025 when Deno 2.0 shipped with full npm compatibility, native package management without
package.json, and stable JSR (JavaScript Registry) support. By 2026 the runtime is a credible third option alongside Node and Bun for JavaScript-based scraping, with a unique angle: permission-based sandboxing. A Deno scraper can be denied disk access, network access to specific hosts, or environment variables at runtime. For untrusted scraper code (third-party plugins, customer-supplied scripts), this is uniquely valuable.This guide covers what Deno offers for scraping in 2026, the libraries that work best, the npm packages that work via Deno’s compatibility shim, and the production patterns that exploit Deno’s strengths. Code is TypeScript throughout. By the end you will know whether Deno fits your project and how to deploy it without the sharp edges.
Why Deno for scraping
Deno’s specific strengths for scrapers:
- Permission system: granular runtime permissions for filesystem, network, env vars
- TypeScript native: no transpile step, no tsconfig wrangling
- Web Standards APIs: fetch, ReadableStream, WebCrypto are all Web API spec
- JSR registry: faster, secure alternative to npm with better TypeScript support
- Built-in formatter, linter, tester, bundler: no separate tools
- Single binary: easy install, no node_modules
- Deno Deploy: edge serverless that runs Deno natively, free egress
For Deno’s official documentation, see docs.deno.com.
Where Deno does not lead
- Pure speed: Bun is faster for most workloads
- npm compatibility: better than Bun for some edge cases, worse for others
- Community size: smaller than Node, smaller than Bun in 2026
- Production maturity: behind Node, comparable to Bun
For a project where speed is the deciding factor, Bun. For maximum compatibility, Node. For permission-sandboxed code, Deno.
Installing Deno
curl -fsSL https://deno.land/install.sh | sh # or via brew brew install deno deno --version # 2.0+ in 2026A first scraper
// scrape.ts import { DOMParser } from "jsr:@b-fuze/deno-dom"; async function scrape(url: string) { const resp = await fetch(url, { headers: { "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", }, }); const html = await resp.text(); const doc = new DOMParser().parseFromString(html, "text/html"); if (!doc) { throw new Error("Failed to parse HTML"); } const titles = Array.from(doc.querySelectorAll("h2.title")) .map((el) => el.textContent.trim()); return titles; } const url = Deno.args[0]; if (!url) { console.error("Usage: deno run --allow-net scrape.ts <url>"); Deno.exit(1); } const titles = await scrape(url); console.log(JSON.stringify(titles, null, 2));Run with explicit network permission:
deno run --allow-net=example.com scrape.ts https://example.com/productsThe
--allow-net=example.comrestricts network access to only that host. Try to fetch any other URL and Deno blocks it. This is the security model: code runs only with the permissions you grant.The permission model
Deno permissions for scrapers:
permission flag use Network --allow-net=host1,host2fetch outbound Read FS --allow-read=pathread files Write FS --allow-write=pathwrite files Env vars --allow-env=VAR1,VAR2read environment Subprocesses --allow-runspawn external processes FFI --allow-ffinative library calls Workers included by default start Web Workers All --allow-all(or-A)bypass all checks For a scraper, typical permissions:
deno run \ --allow-net=target.example.com,api.example.com \ --allow-read=./config \ --allow-write=./output \ --allow-env=API_KEY,PROXY_URL \ src/main.tsThis is the discipline that makes Deno safer for running untrusted scraper modules: each module gets only what it needs.
Library survey
The major libraries and their 2026 state:
library purpose source maturity deno-dom HTML parsing, DOM API jsr:@b-fuze/deno-dom excellent cheerio HTML parsing, jQuery-style npm:cheerio excellent (via npm: specifier) linkedom HTML parsing, DOM API npm:linkedom excellent puppeteer browser automation npm:puppeteer good (Node compat) playwright browser automation npm:playwright partial (Node compat) astral Deno-native browser automation jsr:@astral/astral very good got HTTP client npm:got excellent (via npm:) axios HTTP client npm:axios excellent (via npm:) Crawlee crawler framework npm:crawlee excellent p-queue concurrency control npm:p-queue excellent JSR-published packages (jsr:@scope/name) are Deno-native and tend to have better TypeScript support. npm: packages work via the compat layer and cover most ecosystem libraries.
deno-dom for HTML parsing
deno-dom is the standard HTML parser for Deno. WASM-backed, fast, and exposes the browser DOM API:
import { DOMParser } from "jsr:@b-fuze/deno-dom"; const html = await fetch("https://example.com").then(r => r.text()); const doc = new DOMParser().parseFromString(html, "text/html"); // Standard DOM API const title = doc?.querySelector("h1")?.textContent; const links = Array.from(doc?.querySelectorAll("a[href]") || []) .map(a => a.getAttribute("href")); const products = Array.from(doc?.querySelectorAll("article.product") || []) .map(p => ({ title: p.querySelector("h2")?.textContent?.trim(), price: p.querySelector(".price")?.textContent?.trim(), }));Performance is comparable to cheerio for typical HTML sizes. For very large documents, both are roughly equal.
Astral for browser automation
Astral is the Deno-native equivalent of Puppeteer. It runs Chromium with a TypeScript-first API:
import { launch } from "jsr:@astral/astral"; const browser = await launch(); const page = await browser.newPage("https://example.com/products"); // Wait for content to render await page.waitForSelector("article.product"); // Extract via page evaluation const products = await page.evaluate(() => { return Array.from(document.querySelectorAll("article.product")).map((el) => ({ title: el.querySelector("h2")?.textContent?.trim(), price: el.querySelector(".price")?.textContent?.trim(), })); }); await browser.close(); console.log(products);Astral is lighter than Puppeteer and integrates better with Deno’s permission model. For full Playwright feature parity, use the npm:playwright package; for cleaner Deno-first integration, Astral.
Crawlee on Deno
Crawlee, originally a Node framework, runs on Deno via npm compat:
import { CheerioCrawler } from "npm:crawlee"; const crawler = new CheerioCrawler({ async requestHandler({ request, $, enqueueLinks }) { const title = $("h1").text(); console.log(`${request.url}: ${title}`); // Enqueue links from this page await enqueueLinks({ selector: "a[href*='/product/']", }); }, maxRequestsPerCrawl: 100, }); await crawler.run(["https://example.com/products"]);Crawlee’s CheerioCrawler is for HTML scraping, PuppeteerCrawler and PlaywrightCrawler for browser-based. All three work on Deno.
Stealth on Deno
Deno’s built-in fetch uses Hyper (Rust HTTP client) which has a distinct TLS fingerprint. For TLS-fingerprinted targets:
- Use curl-impersonate via subprocess: requires
--allow-run - Use Astral or Puppeteer for full browser: heavier but bypass TLS check entirely
- Use undici via npm: with custom agent: limited stealth options
The cleanest path for stealth is Astral with Chromium because the TLS fingerprint then matches real Chrome:
import { launch } from "jsr:@astral/astral"; const browser = await launch({ args: ["--disable-blink-features=AutomationControlled"], }); const page = await browser.newPage(); await page.goto("https://target.example.com"); const html = await page.content(); await browser.close();For broader fingerprinting context, see TLS fingerprinting in 2026.
Comparison: Deno vs Bun vs Node
dimension Deno 2 Bun 1.1 Node 20 TypeScript native yes yes no (transpile) Web Standards APIs full most partial Permission system yes no no Built-in test runner yes yes yes Built-in fmt/lint yes yes no npm compat very good very good native JSR registry yes partial no Speed (typical scraping) medium fast slow Memory footprint medium small large Production maturity good good excellent For new scraping projects where security and TypeScript ergonomics matter, Deno. For raw speed, Bun. For library compatibility above all, Node.
For Bun specifically, see scraping with Bun runtime: 2026 performance benchmarks.
Deno Deploy: edge serverless scraping
Deno Deploy is the serverless platform that runs Deno scripts at the edge. Similar to Cloudflare Workers but Deno-native:
// main.ts Deno.serve(async (req) => { const url = new URL(req.url).searchParams.get("url"); if (!url) return new Response("Missing url param", { status: 400 }); try { const resp = await fetch(url); const html = await resp.text(); // Extract titles const titles = [...html.matchAll(/<h2[^>]*>(.*?)<\/h2>/g)].map(m => m[1]); return Response.json({ url, titles }); } catch (err) { return Response.json({ error: err.message }, { status: 500 }); } });Deploy:
deployctl deploy --project=my-scraper main.tsDeno Deploy gives you global edge distribution, free egress, and no cold start. For lightweight scraping APIs, it is competitive with Cloudflare Workers.
For serverless comparison, see running scrapers on Cloudflare Workers in 2026.
Production patterns
A production Deno scraper layout:
my-scraper/ ├── src/ │ ├── main.ts │ ├── fetch.ts │ ├── parse.ts │ └── store.ts ├── deno.json # config + dependencies + tasks ├── deno.lock # lockfile └── Dockerfiledeno.jsonexample:{ "tasks": { "dev": "deno run --watch --allow-net --allow-read --allow-write src/main.ts", "start": "deno run --allow-net --allow-read --allow-write src/main.ts", "test": "deno test --allow-net=test.example.com src/", "fmt": "deno fmt", "lint": "deno lint" }, "imports": { "@b-fuze/deno-dom": "jsr:@b-fuze/deno-dom@^0.1.45", "cheerio": "npm:cheerio@^1.0.0", "p-queue": "npm:p-queue@^8.0.1" } }Run tasks via
deno task dev,deno task test, etc.Container deployment:
FROM denoland/deno:2.0 WORKDIR /app COPY deno.json deno.lock ./ COPY src ./src RUN deno cache src/main.ts USER deno EXPOSE 8000 CMD ["run", "--allow-net", "--allow-read", "src/main.ts"]Pre-cache dependencies at build time so runtime is fast.
Long-running scraping
For continuous scrapers:
// src/main.ts let shutdown = false; Deno.addSignalListener("SIGINT", () => { shutdown = true; }); Deno.addSignalListener("SIGTERM", () => { shutdown = true; }); async function main() { while (!shutdown) { const url = await getNextURL(); if (!url) { await new Promise((r) => setTimeout(r, 5000)); continue; } try { await scrapeOne(url); } catch (err) { console.error(`Error on ${url}:`, err); } } console.log("Shutting down"); } await main();Deno’s signal handling matches Node’s pattern, just with the
Deno.addSignalListenerAPI.Concurrency: Workers and parallel scraping
Deno supports Web Workers natively:
// src/main.ts const worker = new Worker(new URL("./scrape-worker.ts", import.meta.url).href, { type: "module", deno: { permissions: { net: ["target.example.com"] }, }, }); worker.onmessage = (e) => console.log("Worker result:", e.data); worker.postMessage({ url: "https://target.example.com/page1" });Workers can have their own permission set, separate from the main script. This is unique to Deno among the JS runtimes.
For multi-process parallelism (CPU-bound), spawn multiple Deno processes:
const procs = await Promise.all( Array.from({ length: 4 }, (_, i) => new Deno.Command("deno", { args: ["run", "--allow-net", "src/scrape-worker.ts", String(i)], }).output() ) );Common pitfalls
- npm: imports require explicit version: pin in deno.json or use exact version in import
- CORS in Deno Deploy: edge functions enforce CORS; configure response headers
- Permission errors at runtime: scripts crash if you forget to grant a permission. Test with
--allow-allthen narrow down. - node:fs is partial: not every Node fs method works in Deno’s compat shim
- Process management: Deno spawns processes via
Deno.Command, not Node’schild_process(though npm compat exposes it) - Bun-specific code does not run on Deno:
Bun.write,bun:sqliteneed rewrites
Operational checklist
For production Deno scrapers in 2026:
- Deno 2.0+ on Linux for production
- denoland/deno:2.0 base image for containers
- JSR for Deno-native packages, npm: for the rest
- Pre-cache dependencies at build time
- Use granular permissions in production
- Use Deno Deploy for edge serverless scraping
- Consider Astral for Deno-native browser automation
- Crawlee works for crawler frameworks
- For TLS-sensitive targets, use Astral or curl-impersonate via subprocess
- Bench against Bun if speed matters; Deno is usually mid-pack
When to choose Deno over Bun
The cases where Deno wins despite being slower:
- You need permission-sandboxed code (multi-tenant, plugin architecture, untrusted modules)
- You want a single runtime for scraping AND deployment to Deno Deploy
- TypeScript-first ergonomics matter and Bun’s TS support has edge cases
- JSR’s better TypeScript inference is meaningful for your codebase
- You want maximum Web Standards conformance
For pure speed, Bun. For permission control or Deno Deploy fit, Deno.
FAQ
Q: how complete is npm compatibility in Deno 2 in 2026?
Very high. Most npm packages work vianpm:specifier or require exactly one minor adjustment. Native binding modules (sharp, sqlite3) are the most common holdouts. Pure JavaScript packages almost always work.Q: should I rewrite my Node scrapers in Deno?
Only if you specifically value Deno’s permission model or want to deploy to Deno Deploy. For pure speed gain, switch to Bun instead. For better TypeScript ergonomics with Node compat, switch to TypeScript with tsx if you have not already.Q: how does Deno Deploy compare to Cloudflare Workers?
Both are edge serverless with free egress. Workers have larger ecosystem (KV, R2, D1, Durable Objects, Browser Rendering API). Deno Deploy is leaner but integrates with Deno KV. For complex distributed scraping, Workers. For lightweight Deno-native APIs, Deno Deploy.Q: what about Deno’s built-in KV store?
Deno KV is a built-in key-value store available locally and on Deno Deploy. For scraper state (visited URLs, simple results), it works well. Less feature-rich than Cloudflare KV but native to Deno.Q: is Deno faster than Node for scraping?
Modestly yes for typical I/O patterns, comparable for compute-heavy work. Bun is faster than both. The order is usually Bun > Deno > Node by 20-50% per dimension.Common pitfalls in production Deno scraping
The first failure mode is permission scope drift in Workers. When you spawn a Web Worker with
deno: { permissions: { net: ["target.example.com"] } }, the worker can only fetch from that domain. If your scraper later needs to fetch from a CDN (target.cdn-cgi.com), the fetch silently throws PermissionDenied. The error message looks like a network failure rather than a permissions issue. The fix is to pre-compute the set of all hostnames the worker might touch (including subdomains, CDNs, and analytics endpoints) and grant them all at worker creation, or use the wildcardnet: truein development and tighten in production:const worker = new Worker(workerUrl, { type: "module", deno: { permissions: { net: [ "target.example.com", "*.target.example.com", "cdn.target.com", "fonts.googleapis.com", // common transitive ], }, }, });The second pitfall is the npm compat shim’s quirky behavior with packages that read package.json at runtime. Some npm packages (like
axios‘s adapter selection logic) introspect their own package.json to detect the runtime environment. Under Deno’s npm compat layer, the detection returns “Node” but the actual runtime is Deno, leading to subtle bugs where the package picks the wrong code path. The mitigation is to test each npm package end-to-end on Deno before relying on it in production, and to prefer JSR-native packages where possible. Common gotchas include:axios(use Deno’s fetch instead),winston(some transports do file ops that conflict with Deno’s permission model), andpuppeteer(works but heavy; use Astral instead).The third pitfall is Deno KV consistency under high-concurrency writes. Deno KV uses optimistic concurrency control with versioned reads. If you have 50 workers all trying to update the same dedupe set with
kv.set(["visited", url], true), most writes succeed but a fraction get versionstamp conflicts that you have to retry. The fix is atomic transactions with explicit conflict handling:async function markVisited(url: string): Promise<boolean> { const kv = await Deno.openKv(); for (let attempt = 0; attempt < 3; attempt++) { const existing = await kv.get(["visited", url]); if (existing.value !== null) return false; // already visited const result = await kv.atomic() .check({ key: ["visited", url], versionstamp: existing.versionstamp }) .set(["visited", url], { ts: Date.now() }) .commit(); if (result.ok) return true; // we won the race } return false; // gave up after retries }Without the retry loop, ~5 percent of writes silently fail under 50-worker concurrency, leading to duplicate processing. With the retry loop, the duplicate rate drops to under 0.1 percent.
Real-world example: Deno Deploy edge scraper for 200 sites
A team built a price-comparison API on Deno Deploy that fetched live prices from 200 ecommerce sites. Each API request triggered fetches to 5-10 sites in parallel, parsed the HTML for current prices, and returned a normalized JSON response. The architecture used Deno KV for caching, Deno Deploy for global distribution, and JSR-native libraries for parsing:
// main.ts import { DOMParser } from "@b-fuze/deno-dom"; const kv = await Deno.openKv(); async function fetchPrice(url: string): Promise<number | null> { // Check 5-min cache first const cached = await kv.get<{price: number, ts: number}>(["price", url]); if (cached.value && Date.now() - cached.value.ts < 5 * 60 * 1000) { return cached.value.price; } try { const resp = await fetch(url, { headers: { "user-agent": "Mozilla/5.0 (compatible; PriceComparator/1.0)", "accept": "text/html", }, signal: AbortSignal.timeout(8000), }); if (!resp.ok) return null; const html = await resp.text(); const doc = new DOMParser().parseFromString(html, "text/html"); const priceEl = doc?.querySelector('[itemprop="price"]') || doc?.querySelector('.price') || doc?.querySelector('[data-price]'); const price = parseFloat( priceEl?.getAttribute("content") || priceEl?.textContent || "" ); if (isNaN(price)) return null; await kv.set(["price", url], { price, ts: Date.now() }, { expireIn: 600_000 }); return price; } catch { return null; } } Deno.serve(async (req) => { const url = new URL(req.url); const targets = url.searchParams.getAll("url"); const prices = await Promise.all(targets.map(fetchPrice)); return new Response( JSON.stringify(targets.map((u, i) => ({ url: u, price: prices[i] }))), { headers: { "content-type": "application/json" } }, ); });Performance: median response 240ms (5 parallel fetches with cache hits common), p95 980ms, p99 2.1s. Monthly Deno Deploy bill at 4 million API calls: $32 (well within the included tier). The same workload on AWS Lambda + DynamoDB would have run roughly $180/month, dominated by Lambda invocation cost and DynamoDB read/write capacity.
The lesson: for read-heavy edge scraping with simple parsing and a cacheable response, Deno Deploy is meaningfully cheaper than AWS-style serverless. The native Deno KV beats Lambda+DynamoDB on both latency and cost for this workload pattern.
Comparison: JSR vs npm imports for scraping libraries
A reference table of which libraries scrapers reach for and where they live in 2026:
library available on recommendation @b-fuze/deno-dom JSR use for HTML parsing, Deno-native cheerio npm only works via npm: import, slightly slower axiod (axios for Deno) JSR discouraged, use built-in fetch @astral/astral JSR use for browser automation, Deno-native puppeteer npm works but heavy; prefer Astral @hono/hono JSR excellent for APIs crawlee npm works, Node compat is solid zod JSR runtime validation, used heavily in scrapers @std/cli JSR Deno standard library, CLI argument parsing postgres JSR + npm both work, JSR version more current Prefer JSR for Deno-native libraries because they get type inference without DefinitelyTyped overhead and are pre-tested against Deno releases. Fall back to npm: imports for ecosystem libraries that have not migrated to JSR yet.
Wrapping up
Deno in 2026 is a credible JavaScript runtime for scraping with a unique permission model that matters in multi-tenant or plugin architectures. The library ecosystem is sufficient: deno-dom for parsing, Astral for browser automation, Crawlee via npm compat for crawler frameworks. For most teams the choice is between Deno’s safety and Deno Deploy fit versus Bun’s raw speed. Pair this with our scraping with Bun runtime and running scrapers on Cloudflare Workers writeups for the full JavaScript-runtime picture, and browse the dev-tools-projects category on DRT for related infrastructure deep-dives.
-
Scraping with Bun runtime: 2026 performance benchmarks
Scraping with Bun runtime: 2026 performance benchmarks
Scraping with Bun runtime is one of the more interesting platform shifts of the past two years. Bun reached 1.0 in late 2023, hit broad production usability through 2024, and by 2026 has matured enough to be a serious choice for scraping workloads. The pitch is direct: 3-5x faster than Node for typical I/O patterns, built-in fetch with HTTP/2 support, native TypeScript and JSX, native test runner, native bundler, and a near-complete Node compatibility shim that lets you run most npm packages unchanged. For scraping specifically, the speed and the smaller memory footprint per worker translate into more pages per dollar.
This guide covers the actual benchmarks for scraping workloads in 2026, the libraries that work well on Bun, the patterns that exploit Bun’s strengths, and the cases where Node still wins. Code is TypeScript throughout. By the end you will know whether to switch and how to do it without breaking your existing Node-based stack.
Why Bun for scraping
Bun’s specific advantages for scrapers:
- Faster startup: ~5ms vs Node’s ~30ms. Big deal for serverless or short scripts.
- Faster JSON parse: 2-3x Node’s speed via SIMD-optimized parsing
- Faster fetch: Bun’s HTTP client is implemented in Zig, lower overhead than Node’s
- Native HTTP/2: no separate package needed
- Built-in WebSocket client and server: useful for real-time scraping
- Smaller memory footprint: ~30 MB resident vs Node’s ~50 MB
- TypeScript by default: no transpilation, no ts-node, no tsconfig dance
- Built-in SQLite: no external dependency for local state
For Bun’s official documentation, see bun.sh/docs.
Benchmarks: Bun vs Node vs Deno vs Python
Real numbers measured in March 2026 on an M3 Pro with 16 GB RAM, scraping a fixed set of 1000 pages from a controlled local nginx server (eliminates network variance):
metric Bun 1.1 Node 20 LTS Deno 1.45 Python 3.12 (httpx) startup time 5ms 32ms 45ms 60ms 1000 fetches sequential 18s 28s 22s 45s 1000 fetches concurrent (50 parallel) 1.2s 2.1s 1.8s 4.5s HTML parse 100 pages 0.4s 0.9s 0.7s 1.1s JSON.parse 10MB document 45ms 130ms 80ms 250ms memory per worker 32 MB 56 MB 48 MB 38 MB package install (lodash + cheerio + p-queue) 0.8s 4.2s 3.5s 2.1s Bun is clearly faster across the board for these workloads. The biggest wins are startup time (6x) and JSON parse (3x). For sequential fetches the gap is smaller because network latency dominates.
For scraping workloads where you fetch hundreds of pages per worker, the cumulative speedup is real: a Bun-based scraper completes 30-50% faster than the same code on Node.
Installing Bun
curl -fsSL https://bun.sh/install | bash # or via brew brew tap oven-sh/bun brew install bun bun --version # 1.1.0+ in 2026For containerized deployment, use the official Bun image:
FROM oven/bun:1.1-slim WORKDIR /app COPY package.json bun.lockb ./ RUN bun install --frozen-lockfile COPY . . CMD ["bun", "run", "src/index.ts"]The image is ~70 MB, smaller than Node images.
A first scraper
// src/scrape.ts import * as cheerio from "cheerio"; interface Product { title: string; price: string; url: string; } async function scrape(url: string): Promise<Product[]> { const resp = await fetch(url, { headers: { "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) 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", }, }); if (!resp.ok) { throw new Error(`HTTP ${resp.status} for ${url}`); } const html = await resp.text(); const $ = cheerio.load(html); const products: Product[] = []; $("article.product").each((_, el) => { products.push({ title: $(el).find("h2").text().trim(), price: $(el).find(".price").text().trim(), url: $(el).find("a").attr("href") || "", }); }); return products; } const url = process.argv[2]; if (!url) { console.error("Usage: bun run scrape.ts <url>"); process.exit(1); } const products = await scrape(url); console.log(JSON.stringify(products, null, 2));Run:
bun run src/scrape.ts https://example.com/productsBun runs the TypeScript file directly without a build step. fetch is built in. JSON.stringify uses Bun’s faster implementation automatically.
Concurrent scraping with p-queue
For controlled concurrency, p-queue works on Bun:
// src/concurrent.ts import PQueue from "p-queue"; import * as cheerio from "cheerio"; const URLS = Array.from( { length: 100 }, (_, i) => `https://example.com/products?page=${i + 1}` ); const queue = new PQueue({ concurrency: 20 }); async function fetchOne(url: string) { const resp = await fetch(url, { headers: { "User-Agent": "Mozilla/5.0 ..." }, }); const html = await resp.text(); const $ = cheerio.load(html); const titles = $("h2.product-title").map((_, el) => $(el).text().trim()).get(); return { url, count: titles.length }; } const results = await Promise.all( URLS.map((url) => queue.add(() => fetchOne(url))) ); console.log(`Scraped ${results.length} pages`); console.log(`Total products: ${results.reduce((sum, r) => sum + (r?.count ?? 0), 0)}`);p-queue handles backpressure: at most 20 fetches run concurrently. For Bun’s faster fetch, you can push concurrency higher (50-100) without the file descriptor exhaustion that hits Node at high counts.
Built-in SQLite for scraper state
Bun ships with SQLite as a first-class module:
// src/state.ts import { Database } from "bun:sqlite"; const db = new Database("scraper.db"); db.exec(` CREATE TABLE IF NOT EXISTS pages ( url TEXT PRIMARY KEY, fetched_at INTEGER, status INTEGER, content TEXT ) `); const insert = db.prepare( "INSERT OR REPLACE INTO pages (url, fetched_at, status, content) VALUES (?, ?, ?, ?)" ); const lookup = db.prepare("SELECT url, status FROM pages WHERE url = ?"); export function isScraped(url: string): boolean { return lookup.get(url) !== null; } export function recordScrape(url: string, status: number, content: string) { insert.run(url, Date.now(), status, content); }Bun’s SQLite is faster than the better-sqlite3 npm package and requires no install step. For per-scraper local state, this is the simplest option.
Stealth on Bun: HTTPS fingerprinting
Bun’s built-in fetch uses uSockets (Bun’s HTTP client) which has its own TLS fingerprint. Sites that check JA4 see “Bun” not “Chrome.” For TLS-fingerprinted targets, you have two options:
- Use curl_cffi via child process: shell out to curl with –impersonate
- Use Playwright through bun-compat: heavy but works
For lighter targets where TLS is not checked, Bun’s built-in fetch is fine. For heavy targets, the curl-via-shell approach:
// src/stealth-fetch.ts import { spawn } from "bun"; async function curlFetch(url: string, impersonate = "chrome124"): Promise<string> { const proc = spawn([ "curl", "--impersonate", impersonate, "-s", "--max-time", "30", url, ]); const html = await new Response(proc.stdout).text(); await proc.exited; return html; }This requires curl-impersonate installed in your environment. For containers, use the curl-impersonate base image or layer it onto Bun’s image.
Playwright on Bun
Playwright works on Bun via npm compatibility:
bun add playwright bunx playwright install chromiumUse as in Node:
import { chromium } from "playwright"; const browser = await chromium.launch({ headless: true }); const page = await browser.newPage(); await page.goto("https://example.com"); const html = await page.content(); await browser.close();Playwright’s overhead is the same as on Node (it spawns a browser process). The gain on Bun is in your wrapper code, not in the browser itself.
HTML parsing libraries
For HTML parsing on Bun:
library speed API bundled in Bun cheerio fast jQuery-like no, install via bun add node-html-parser fastest DOM-like no linkedom medium DOM API no Bun’s built-in HTMLRewriter streaming callback yes (via Web API) For most cases, cheerio is the right pick. For very large HTML and streaming use cases, HTMLRewriter is faster:
// HTMLRewriter for streaming parse const resp = await fetch("https://example.com/large-page"); const titles: string[] = []; const rewriter = new HTMLRewriter().on("h2.title", { text(text) { if (text.text.trim()) titles.push(text.text); }, }); const transformed = rewriter.transform(resp); await transformed.text(); // consume the stream console.log(titles);HTMLRewriter never loads the full document into memory. For pages over a few MB, this is a meaningful difference.
Comparison: Bun vs Node vs Deno for scraping
dimension Bun Node 20 Deno 1.45 startup time 5ms 32ms 45ms TypeScript native yes no (transpile) yes Built-in fetch yes (fast) yes (slower) yes Built-in SQLite yes no no Built-in test runner yes yes (Node 20+) yes Built-in bundler yes no (use esbuild) yes (deno bundle deprecated) npm compat very high native high (via npm: specifier) Playwright support yes yes partial Production maturity very good in 2026 excellent good Community size medium very large medium For new scraping projects in 2026, Bun is the right pick when speed matters. Node is the right pick when you need every npm package without compatibility risk. Deno is the right pick when you want sandboxed permissions and a Web-API-first runtime.
For Deno specifically, see Deno scraping libraries 2026 reviewed.
Production patterns
A production Bun scraper structure:
my-scraper/ ├── src/ │ ├── index.ts # entry point │ ├── fetch/ │ │ ├── stealth.ts # curl-impersonate wrapper │ │ └── basic.ts # built-in fetch wrapper │ ├── parse/ │ │ └── cheerio.ts # HTML parsing │ ├── store/ │ │ └── sqlite.ts # SQLite state │ └── queue/ │ └── pqueue.ts # concurrency control ├── package.json ├── bun.lockb ├── tsconfig.json └── DockerfileFor long-running scrapers, use Bun’s process management:
// src/index.ts import { signal } from "bun"; let shutdown = false; process.on("SIGTERM", () => { shutdown = true; }); process.on("SIGINT", () => { shutdown = true; }); async function main() { while (!shutdown) { const url = await getNextURL(); if (!url) { await Bun.sleep(5000); continue; } try { await scrapeOne(url); } catch (err) { console.error(`Error on ${url}:`, err); } } console.log("Shutting down gracefully"); } main();Bun.sleepis a built-in async sleep, no need for a Promise wrapper.Workers and concurrency
Bun supports Worker threads similar to Node:
// src/worker.ts import { Worker } from "node:worker_threads"; const workers = Array.from({ length: 4 }, () => new Worker("./src/scraper-worker.ts")); workers.forEach((w, i) => { w.postMessage({ workerId: i, urls: getUrlsForWorker(i) }); w.on("message", (msg) => console.log(`Worker ${i}:`, msg)); });Each worker is a separate Bun process with isolated memory. For CPU-bound work like heavy HTML parsing, this scales well. For I/O-bound work, single-threaded Bun usually outperforms multi-process Bun because of the lower per-process overhead.
Cost analysis: Bun vs Node for cloud scraping
Running a scraping workload that processes 10 million pages/month on AWS:
dimension Bun Node EC2 instance class c7i.large c7i.xlarge RAM utilization 60% 90% CPU utilization 70% 80% pages/sec 35 22 instance count 6 11 monthly cost (24/7) $720 $1,320 Bun’s lower memory footprint lets you fit more concurrent workers per machine, and the faster fetch means each worker handles more pages per second. The combined effect is roughly half the infrastructure cost.
Common pitfalls
- Native modules with C++ bindings: most work on Bun’s Node compat, but some (sqlite3, sharp) have edge cases. Test thoroughly.
- Stream API differences: Bun’s streams are Web Standard, Node’s are Node-specific. If your code uses Node streams heavily, watch for compatibility issues.
- Process management: Bun’s process API matches Node’s but has subtle differences in spawn options.
- Date.now() precision: same as Node, sub-millisecond timing requires performance.now().
- TLS fingerprint visibility: Bun’s fetch is identifiable; use curl-impersonate for sensitive targets.
Operational checklist
For production Bun scrapers in 2026:
- Bun 1.1+ on Linux for production
- oven/bun:1.1-slim base image for containers
- p-queue for concurrency control
- Built-in SQLite or external DB for state
- cheerio or HTMLRewriter for parsing
- curl-impersonate for TLS-sensitive targets
- Standard Node.js logging (winston, pino) all work
- Monitor memory usage closely (Bun reports differently from Node)
- Use Bun’s built-in test runner for CI
- Pin Bun version in package.json’s “engines” field
FAQ
Q: should I rewrite my Node scrapers in Bun?
If they fit Bun’s strengths (lots of fetches, JSON parsing, TypeScript) yes. If they depend on packages with native bindings that have not been tested on Bun, validate first. The migration is usually low-effort.Q: how stable is Bun in production in 2026?
Very stable. Bun 1.1 is what most teams ran in production through 2024-2025, and 1.x is the current production line. Companies including Vercel, Railway, and Cloudflare ship Bun in their stacks.Q: does Bun support all Node modules?
Most. Built-in modules (fs, http, child_process) are well covered. Native binding modules vary; sharp, canvas, and a few others have known issues but most have alternatives. Check the Bun compat list before committing.Q: can I use Bun in serverless?
Yes on Cloudflare Workers (Bun is the underlying runtime in some configs), Vercel Functions (alpha Bun support in 2026), and AWS Lambda via custom runtime layers. Cold start is significantly faster than Node.Q: does Bun have anything like Scrapy?
Not directly. Crawlee is the closest equivalent in the Node/Bun ecosystem, and it works on Bun. For Python-style Scrapy you stay in Python.Common pitfalls in production Bun scraping
The first failure mode is the
bun.lockbbinary lockfile diverging across team machines. Bun stores its lockfile in a binary format (bun.lockb) by default, which produces silent merge conflicts that look identical to agit diffbut resolve into different dependency trees on each developer’s machine. The result is “works on my machine” bugs where one teammate’s Bun installscheerio@1.0.0-rc.12while another getscheerio@1.0.0because their resolved transitive dependencies diverged. The fix is to set"saveTextLockfile": trueinbunfig.tomlso Bun emits a text-format lockfile that diffs cleanly:# bunfig.toml [install] saveTextLockfile = true exact = trueThen commit
bun.lock(text format) instead ofbun.lockb. Addbun.lockbto.gitignore.The second pitfall is Bun’s built-in fetch keepalive default behavior. Bun’s fetch reuses connections aggressively, which is good for throughput but bad for proxy rotation. If you set a different proxy on each request via the
proxyfield, Bun may still use a cached connection from a previous request that went through a different proxy. The fix is to explicitly setkeepalive: falseor to use a freshbun:fetchinstance per proxy:async function scrapeWithProxy(url: string, proxy: string) { const resp = await fetch(url, { proxy, keepalive: false, // force new connection headers: { "user-agent": "Mozilla/5.0 ..." }, }); return await resp.text(); }The third pitfall is the
Bun.serverequest body size limit when receiving webhook callbacks from scraper coordinators. Bun.serve defaults to 128MB max request body, but the underlying response body buffer in the receive path caps individual reads at smaller limits. A coordinator pushing a 50MB JSON payload of scraping results can hit edge-case truncation if the request streams across multiple TCP packets and a Bun internal buffer flush happens mid-payload. SetmaxRequestBodySizeexplicitly and stream-decode JSON withBun.readableStreamToJSON()rather than buffering the whole body:Bun.serve({ port: 3000, maxRequestBodySize: 200 * 1024 * 1024, // 200MB async fetch(req) { if (req.method === "POST") { const data = await Bun.readableStreamToJSON(req.body!); return await processBatch(data); } return new Response("OK"); }, });Real-world example: rewriting a Node scraper in Bun
A team migrating a 14,000-line Node.js scraper to Bun documented the changes required for clean operation. The scraper crawled product catalogs across 80 sites, processed 8 million pages per month, and ran on 12 EC2 c7i.xlarge instances. The migration took 5 days and produced these specific changes:
// 1. Replace node-fetch with built-in fetch (no import) - import fetch from "node-fetch"; + // Bun has fetch globally // 2. Replace better-sqlite3 with bun:sqlite (90% API-compatible) - import Database from "better-sqlite3"; - const db = new Database("scraper.db"); + import { Database } from "bun:sqlite"; + const db = new Database("scraper.db"); // 3. Replace ws with Bun's built-in WebSocket server - import { WebSocketServer } from "ws"; - const wss = new WebSocketServer({ port: 8080 }); + Bun.serve({ + port: 8080, + fetch(req, server) { + if (server.upgrade(req)) return; + return new Response("Expected upgrade"); + }, + websocket: { + message(ws, message) { /* handle */ }, + }, + }); // 4. Replace fs.promises with Bun.file API for hot paths - await fs.promises.writeFile("output.json", JSON.stringify(data)); + await Bun.write("output.json", JSON.stringify(data)); // 5. Replace bcrypt with Bun.password (built-in) - import bcrypt from "bcrypt"; - const hash = await bcrypt.hash(pwd, 10); + const hash = await Bun.password.hash(pwd);After migration, the scraper’s per-page latency dropped from 180ms (Node) to 110ms (Bun), memory usage per worker dropped from 320MB to 180MB, and the team consolidated from 12 c7i.xlarge instances down to 7. Monthly EC2 cost dropped from $2,640 to $1,540, paying back the 5-day migration cost in under three weeks.
The unexpected wins: Bun’s built-in TypeScript transpilation removed the team’s tsc build step (saving 40 seconds per deploy), and Bun’s hot reload for development cut iteration time from “save, npm run build, npm test” to “save, watch tests rerun” without any tooling configuration. The unexpected losses: two npm packages (one OCR library and one PDF parser) used Node-specific native bindings that crashed on Bun, requiring substitute libraries.
Comparison: Bun ecosystem maturity by category
A reference table of which scraping-adjacent libraries work cleanly on Bun in 2026:
category works on Bun partial broken HTTP fetching built-in fetch, undici, axios node-fetch (deprecated) none HTML parsing cheerio, parse5, htmlparser2 jsdom (slow on Bun) none TypeScript built-in (no tsc needed) n/a n/a SQLite bun:sqlite, better-sqlite3 sqlite3 (older) none Postgres postgres, pg none none Redis ioredis, bun:redis (built-in) redis (older client) none Headless browser puppeteer, playwright patchright (some Chromium quirks) none HTTP server Bun.serve, hono, elysia express (works but slow) koa (some middleware issues) Job queues bullmq bee-queue agenda (Mongoose issues) Logging pino, winston bunyan (older) none Compression bun:zlib (built-in) zlib none For most scraper stacks, every category has a working Bun option. Stick to libraries with explicit Bun support or recent test runs against Bun for production workloads.
Wrapping up
Bun in 2026 is a faster, smaller, and more ergonomic JavaScript runtime that suits scraping workloads particularly well. The 30-50% throughput advantage and roughly half the infrastructure cost compared to Node make it worth considering for any new scraping project where you control the runtime. Pair this with our Deno scraping libraries 2026 and best Node.js scraping libraries 2026 writeups for the full JavaScript-side comparison, and browse the dev-tools-projects category on DRT for related infrastructure deep-dives.
-
Running scrapers on Cloudflare Workers in 2026
Running scrapers on Cloudflare Workers in 2026
Running scrapers on Cloudflare Workers is the kind of architectural decision that sounds clever in a slack thread and either pays off massively or crashes against runtime limits within a week. Workers give you a globally-distributed serverless runtime with sub-50ms cold starts, free egress to anywhere, and tight integration with KV/R2/D1/Durable Objects for state. The catch is that each Worker invocation has a 30-second CPU limit (or 5 minutes with longer-duration plans), 128 MB of memory, no persistent disk, and an aggressive limit on subrequests. For some scraping workloads these limits are fine. For others they are fatal.
This guide covers what Workers can and cannot do for scraping in 2026, the cases where the architecture wins, the limits that bite, and a complete working scraper implementation including state management via Durable Objects, queue management, and the new Browser Rendering API for JS-heavy pages.
What Workers offer that other serverless does not
The Workers value proposition for scrapers:
- Sub-50ms cold start: each invocation feels instant, no Lambda-style 5-second cold starts
- Free egress: outbound HTTPS to anywhere is free, no per-GB egress charges
- 300+ POPs: requests originate from whichever POP is closest to the target, naturally distributing IPs
- Built-in KV, R2, D1: persistent state without separate infrastructure
- Durable Objects: stateful coordination for queues, rate limits, locks
- Browser Rendering API: real Chromium rendering integrated with Workers in 2024
- Cron Triggers: scheduled execution without separate scheduler
For official Cloudflare Workers docs, see Cloudflare’s developer site.
What Workers cannot do for scrapers
The limits that catch teams:
- 30 sec CPU limit per invocation (Workers Standard) or 5 min (Workers Unbound, costs more)
- 128 MB memory (Standard) or 1 GB (Unbound)
- 50 subrequests (Standard) or 1000 (Unbound) per invocation
- No persistent disk: no SQLite files, no temp directories
- Limited Node.js compat: many npm packages do not work
- Outbound IP is Cloudflare’s: no proxy support (requires fetch-via-proxy patterns)
- No long-lived connections: WebSockets supported but not for scraping use
- Browser Rendering API is metered: $0.20 per browser-minute, not free
The CPU limit is the most cutting one. A scraper that fetches 100 pages and parses them all in one invocation will hit 30 seconds easily. The pattern that works is: each Worker invocation does one small unit of work, persists state to KV/D1/Durable Object, and either schedules the next invocation or returns.
When Workers win for scraping
Workers shine when:
- High-volume, light-per-page scraping (1000s of pages, each <1 sec to fetch)
- Geo-distributed scraping (target wants to see traffic from many countries)
- Scheduled lightweight jobs (cron-triggered, completes in seconds)
- API endpoints that need a global edge layer wrapping internal scrapers
- Webhook receivers that trigger scraping flows
- Real-time price monitoring at scale
When Workers do not win:
- Heavy JavaScript-rendered pages (Browser Rendering API helps but adds latency)
- Sites with strict TLS fingerprinting (Worker fetch uses Cloudflare’s fingerprint)
- Pages requiring proxies (no native proxy support)
- Long-running scraping that needs minutes per page
- Stateful scraping with complex local state
The pattern that wins: small, frequent, distributed scraping. The pattern that loses: long, complex, single-machine.
Setup: a basic Worker scraper
Install Wrangler:
npm install -g wrangler wrangler loginCreate a Worker:
wrangler init my-scraper --type javascript cd my-scraperThe basic Worker that fetches a page:
// src/index.js export default { async fetch(request, env, ctx) { const url = new URL(request.url).searchParams.get("url"); if (!url) return new Response("Missing url parameter", { status: 400 }); try { const resp = await fetch(url, { 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", }, }); const html = await resp.text(); // Parse with HTMLRewriter (Worker's built-in HTML parser) const titles = []; const rewriter = new HTMLRewriter().on("h1", { text(text) { if (text.text.trim()) titles.push(text.text); }, }); await rewriter.transform(new Response(html)).text(); return new Response(JSON.stringify({ url, titles }), { headers: { "Content-Type": "application/json" }, }); } catch (err) { return new Response(JSON.stringify({ error: err.message }), { status: 500 }); } }, };Deploy:
wrangler deployThis Worker accepts a
urlquery parameter, fetches it, extracts H1 titles, and returns JSON. Try it:curl https://my-scraper.your-subdomain.workers.dev?url=https://example.com.Storage layer: KV, R2, D1, Durable Objects
Each storage option has tradeoffs:
storage use case latency cost KV global k-v store, eventually consistent <50ms read $0.50/M reads R2 object storage (S3-like) varies $0.36/M operations + storage D1 SQL database (SQLite at edge) <50ms $0.001/1k rows read Durable Objects strongly consistent state, stateful <10ms $0.15/M requests Workers Cache HTTP-style cache <10ms included For scraping state:
- Crawl queue: Durable Object (FIFO, strongly consistent)
- Visited URLs (dedup): KV (eventually consistent is fine)
- Scraped data (records): D1 or R2
- Per-host rate limits: Durable Object (atomic counters)
- Page snapshots (HTML, screenshots): R2
A Durable Object for queue management:
// src/queue.js export class CrawlQueue { constructor(state, env) { this.state = state; this.env = env; } async fetch(request) { const url = new URL(request.url); const action = url.searchParams.get("action"); if (action === "push") { const body = await request.json(); const queue = (await this.state.storage.get("queue")) || []; queue.push(...body.urls); await this.state.storage.put("queue", queue); return new Response(JSON.stringify({ size: queue.length })); } if (action === "pop") { const queue = (await this.state.storage.get("queue")) || []; const item = queue.shift(); await this.state.storage.put("queue", queue); return new Response(JSON.stringify({ url: item, remaining: queue.length })); } return new Response("unknown action", { status: 400 }); } }Bind it in
wrangler.toml:[[durable_objects.bindings]] name = "QUEUE" class_name = "CrawlQueue" [[migrations]] tag = "v1" new_classes = ["CrawlQueue"]Use from a Worker:
const id = env.QUEUE.idFromName("global"); const stub = env.QUEUE.get(id); const resp = await stub.fetch("http://queue/?action=pop"); const { url } = await resp.json();The Durable Object guarantees that two Workers calling
popsimultaneously get different items (no race condition).Browser Rendering API
For JavaScript-heavy pages, Cloudflare’s Browser Rendering API gives you Chromium in a Worker. It launched in 2024 and stabilized through 2025.
// wrangler.toml binding // [browser] // binding = "MYBROWSER" import puppeteer from "@cloudflare/puppeteer"; export default { async fetch(request, env) { const url = new URL(request.url).searchParams.get("url"); const browser = await puppeteer.launch(env.MYBROWSER); const page = await browser.newPage(); await page.goto(url, { waitUntil: "networkidle0" }); const html = await page.content(); const screenshot = await page.screenshot(); await browser.close(); // Store screenshot in R2 await env.SCREENSHOTS.put(`${Date.now()}.png`, screenshot); return new Response(html, { headers: { "Content-Type": "text/html" }, }); }, };Pricing: $0.20 per browser-minute. A 5-second page render costs ~$0.017. For 1000 pages, that is $17. Compare to running your own browser pool: usually cheaper at high volume but with operational overhead.
Stealth on Workers
Worker fetch uses Cloudflare’s network. The TLS fingerprint is whatever Cloudflare’s outbound HTTP client uses, which is distinctive. Targets that fingerprint TLS see “Cloudflare Worker” patterns.
Mitigations:
- For HTTP-only sites without TLS fingerprinting: standard fetch is fine
- For sites with light fingerprinting: Browser Rendering API uses real Chromium
- For heavy targets: Workers are not the right tool. Use them as orchestration, route the actual fetches through proxied scrapers elsewhere
Cloudflare also offers Smart Placement which routes Workers to a POP near the upstream service, reducing latency. For scraping, you usually want POPs distributed close to your targets.
Subrequest limits
Each Worker invocation can make up to 50 subrequests on Standard plan, 1000 on Unbound. A subrequest is any external fetch, KV operation, D1 query, etc.
For high-volume scraping, this means each Worker invocation can fetch up to 50 pages in parallel. Beyond that, you need to chain invocations:
async function scrapeWithChunking(urls, env) { const CHUNK_SIZE = 30; // leave headroom under 50 for (let i = 0; i < urls.length; i += CHUNK_SIZE) { const chunk = urls.slice(i, i + CHUNK_SIZE); await Promise.all(chunk.map(url => scrapeOne(url, env))); } }If a single chunk takes 30+ seconds CPU time, split into separate Worker invocations triggered by Cron or by Durable Object scheduling.
Cron Triggers for scheduled scraping
Workers support scheduled execution via Cron Triggers in
wrangler.toml:[triggers] crons = ["*/5 * * * *", "0 0 * * *"] # every 5 min and daily at midnightHandler:
export default { async scheduled(event, env, ctx) { if (event.cron === "*/5 * * * *") { // Run every 5 minutes ctx.waitUntil(scrapePriceUpdates(env)); } if (event.cron === "0 0 * * *") { // Daily full crawl ctx.waitUntil(fullCrawl(env)); } }, };ctx.waitUntillets the work continue past the response return, useful for fire-and-forget scrapers.Comparison: Workers vs other serverless for scraping
platform cold start max duration egress cost proxy support browser support Cloudflare Workers <50ms 30s (5min Unbound) free none native Browser Rendering API AWS Lambda 100-3000ms 15 min $0.09/GB yes via container Google Cloud Functions 100-3000ms 9 min $0.12/GB yes via container Azure Functions 200-3000ms 10 min $0.087/GB yes via container Vercel Functions 100-1000ms 60s (Pro) $0.40/GB limited yes Deno Deploy <50ms 50s free yes no For free egress and global edge distribution, Workers and Deno Deploy lead. For long-running tasks and proxy support, Lambda or GCF win. For browser rendering, Workers’ Browser Rendering API or Lambda with custom container.
For broader serverless-scraping patterns in JS, see Deno scraping libraries 2026 reviewed.
Architecture: Workers + Durable Object + R2
A complete distributed scraper:
Cron Trigger (every 1 min) | v +----------------+ | Worker | reads queue, dispatches subrequests +----------------+ | +--------+--------+ | | v v Durable Object Worker subrequests (queue, state) (parallel fetches) | v KV (dedup) D1 (data) R2 (HTML, screenshots)Each Cron tick: Worker reads up to 50 URLs from the Durable Object queue, fetches them in parallel, parses, writes results to D1 and R2, marks URLs as visited in KV. If queue still has items, schedule another invocation immediately.
This pattern handles tens of thousands of URLs per hour on Workers Standard, scales to millions on Unbound.
Cost analysis
A scraper doing 1 million page fetches per month:
component cost Workers requests (1M) $0.30 Workers CPU time varies, ~$5-20 KV reads (visited check) $0.50 D1 writes (results) $1-3 R2 storage (HTML at 50 KB avg) $0.75 Durable Object requests $0.15 Browser Rendering (10% of pages, 5 sec each) $1,000 Total without browser ~$10/month Total with browser on 10% of pages ~$1,010/month Without browser rendering, Workers are extremely cheap. With browser rendering, they are still competitive but no longer the absolute bargain. For pages that need browser rendering at scale, consider running Playwright on dedicated VMs and reserving Workers for HTTP-only flows.
Common pitfalls
- Cold storage state on first invocation: Durable Objects start empty; handle nil/empty cases
- HTMLRewriter is streaming, not DOM: cannot do complex selector queries
- No npm packages with native code: pure JS only
- Subrequest limit: hits silently when exceeded, your fetch returns an error
- CPU limit applies to async work too: even waiting for a fetch counts if your Worker is processing
- Time limit measured in CPU time: 30 seconds CPU != 30 seconds wall clock
- No persistent disk: cannot save SQLite files, must use D1 or KV
For the Cloudflare Workers limits reference, see Workers limits.
Operational checklist
For production scrapers on Workers in 2026:
- Wrangler 3+ for deploys
- Workers Unbound plan if you need >30s CPU or >50 subrequests
- Durable Objects for queues and rate limits
- KV for dedup and lightweight state
- D1 or R2 for results storage
- Cron Triggers for scheduling
- Monitor CPU time, subrequest count, error rate per Worker
- Use Workers Logpush to stream logs to R2 or external SIEM
- Reserve Browser Rendering API for pages that truly need JS
- For TLS-fingerprinted targets, route through external proxy infrastructure
- Test with
wrangler dev --remotefor true edge testing
FAQ
Q: can I use proxies with Workers?
Not natively. Workers fetch from Cloudflare’s network. To use proxies, you would need to route to a proxy service via fetch (a custom proxy that accepts HTTPS and forwards), which is awkward. For proxy-required scraping, run your scrapers elsewhere.Q: how does Worker fetch’s TLS fingerprint look?
Distinct from any browser. Cloudflare’s outbound HTTP client has its own JA4. Targets that check TLS fingerprints can identify it. For TLS-sensitive targets, do not use Workers for the fetch.Q: is Browser Rendering API a real Chrome?
Yes, it runs Chromium in Cloudflare’s infrastructure. Your Worker connects to it via the puppeteer-compatible API. It supports stealth patches but is metered.Q: can I do long-running scraping on Workers?
Not in a single invocation. Use Durable Objects to break work into small chunks, with each Worker invocation handling one chunk. Coordinate via Cron or self-scheduled triggers.Q: how do Workers compare to Vercel Edge Functions for scraping?
Workers have lower cold start and free egress; Vercel has Edge Functions in similar shape but with usage-based pricing including egress. For raw scraping cost at scale, Workers are cheaper. For Vercel projects already using their platform, Edge Functions integrate natively.Common pitfalls in production Workers scraping
The first failure mode is the subrequest fan-out cliff. Workers Standard caps subrequests at 50 per invocation; Workers Unbound raises this to 1000. A scraper that processes a category page and queues 60 product fetches from a single Worker invocation hits the cap silently: the 51st fetch returns an error in the response object but does not throw. Your loop continues, your error counter never increments because you assumed
fetch()would throw, and you end up with 49 successful fetches and 11 silently dropped requests. The fix is two-part: first, always checkresponse.okandresponse.statusafter every fetch; second, batch outbound fetches into chunks of 40 (leaving headroom for retries) and queue overflow into the Durable Object for the next Cron tick.The second pitfall is HTMLRewriter buffering on slow upstreams. HTMLRewriter is streaming, but if your handler does any async work inside an element callback, it pauses the stream. A handler that fetches a related URL on every
<a href>element it encounters effectively serializes the entire HTML parse, turning a 200ms streaming parse into a 30+ second one (one fetch per anchor). The fix is to collect the URLs synchronously into an array during the parse, then dispatch fetches in parallel after the parse completes:const collectedUrls = []; const rewriter = new HTMLRewriter().on("a[href]", { element(el) { collectedUrls.push(el.getAttribute("href")); // sync only }, }); await rewriter.transform(response).text(); // Now fan out const subResponses = await Promise.all( collectedUrls.slice(0, 40).map((u) => fetch(new URL(u, baseUrl))) );The third pitfall is Durable Object hibernation losing in-memory state. DOs hibernate after about 10 seconds of inactivity, and any state stored in instance variables (not in
state.storage) is lost on wake. Scrapers that maintain a “currently processing” set inthis.processing = new Set()find that the set is empty after every cron-triggered wake, leading to duplicate processing. Always persist coordination state viastate.storage.put()and re-hydrate viastate.storage.get()on every method entry. Hibernation is documented but easy to forget when your localwrangler devkeeps state in memory across reloads.Real-world example: 10M-URL crawl with $40 monthly cost
A scraper team built a price-monitoring system that fetched 10 million product URLs per month across 200 ecommerce sites. The architecture used Workers Cron + Durable Objects + KV + D1 + R2:
// cron.js — runs every minute export default { async scheduled(event, env, ctx) { const queueDO = env.QUEUE_DO.get(env.QUEUE_DO.idFromName("global")); const batch = await queueDO.fetch("https://internal/dequeue?count=40").then(r => r.json()); if (!batch.urls.length) return; const results = await Promise.all(batch.urls.map(async (url) => { try { const r = await fetch(url, { headers: { "user-agent": "Mozilla/5.0 ..." }, cf: { cacheTtl: 0 }, // never cache, always fresh }); if (!r.ok) return { url, error: `status ${r.status}` }; const html = await r.text(); const price = extractPrice(html); // sync HTMLRewriter elsewhere return { url, price, fetched_at: Date.now() }; } catch (e) { return { url, error: e.message }; } })); // Write results to D1 in one batched insert const stmt = env.DB.prepare( "INSERT INTO prices (url, price, fetched_at) VALUES (?, ?, ?)" ); await env.DB.batch(results .filter(r => r.price) .map(r => stmt.bind(r.url, r.price, r.fetched_at)) ); }, };Monthly billing breakdown:
- Workers requests: 14.4M (43,200 cron ticks at 40 fetches + 14M subrequests) = $4.80
- Workers CPU time at avg 80ms per invocation = $11.50
- Durable Object requests = $1.20
- D1 writes (10M rows) = $15
- KV reads (dedup checks) = $5
- R2 storage (sampled raw HTML, 100GB) = $1.50
Total: $39 per month for 10M monitored URLs. The same workload on AWS Lambda + RDS would have cost approximately $850 per month, dominated by RDS instance fees and Lambda invocation costs. The lesson: Workers wins decisively when the workload fits its constraints (no proxies needed, sub-second per page, results structured enough for D1).
Detection: when Workers are the wrong choice
Five signals that your scraping workload should NOT live on Workers:
- Per-request proxy required: Workers cannot rotate through residential proxy pools efficiently. Move to Lambda or VMs.
- Long-running JS challenges: Cloudflare’s own Under Attack mode takes 5-10 seconds, and other JS challenges take similar time. Workers Unbound max is 5 minutes but CPU time is metered separately. Use Browserbase or VM-based Playwright.
- Heavy data parsing: A 5MB HTML page parsed via DOMParser easily exceeds the 30s Workers Standard CPU budget. HTMLRewriter handles streaming HTML but is awkward for non-trivial extraction.
- Stateful sessions across many requests: Cookies and login flows that span dozens of requests work better in long-lived processes.
- Custom TLS fingerprinting: Workers cannot impersonate Chrome’s TLS handshake. Targets that check JA4 see Cloudflare’s outbound fingerprint instead.
If any two of these apply, run your scraper on Fargate, EKS, or self-hosted infrastructure instead.
Wrapping up
Cloudflare Workers are a niche but powerful scraping platform when the workload fits: high volume, distributed, HTTP-light, with state in KV/D1/Durable Objects. Browser Rendering API closes the JS gap at the cost of metered pricing. Pair this with our scrapers vs Playwright integration patterns and Deno scraping libraries for the full serverless picture, and browse the dev-tools-projects category on DRT for related infrastructure deep-dives.
-
Scrapy + Playwright integration in 2026
Scrapy + Playwright integration in 2026
Scrapy + Playwright integration is the standard answer when Scrapy’s speed and pipeline ergonomics meet pages that need a real browser to render. Scrapy alone runs at thousands of pages per second through asynchronous Twisted callbacks but cannot execute JavaScript. Playwright can drive a real browser through any page but is a single-page-at-a-time tool that does not give you Scrapy’s middleware, item pipelines, or deduplication. The
scrapy-playwrightplugin bridges them: Scrapy still handles the orchestration, scheduling, and pipelines, but pages route through Playwright when needed.This guide covers scrapy-playwright 0.0.40+ on Scrapy 2.11+ in 2026, the setup steps, the page coroutines pattern, proxy and stealth integration, and production patterns that scale. Code is Python 3.12 throughout. By the end you will have a Scrapy project that can mix HTML-only requests (fast) and Playwright-rendered requests (slow but JS-capable) in one spider, with shared middleware and pipelines.
Why this combination wins
Scrapy alone is unbeatable for HTML-only scraping at scale. Its async model, request prioritization, deduplication, and pipeline architecture are mature. The weakness is that any JavaScript-rendered page returns empty HTML.
Playwright alone handles JS perfectly but has no concept of crawl orchestration. You write your own request queue, deduplication, retry logic, and item processing.
scrapy-playwright lets each page in a Scrapy spider opt into Playwright via a metadata flag. Pages without the flag stay as fast HTML requests. Pages with the flag route through a Playwright pool, render fully, and return rendered HTML to your callback as if Scrapy fetched it directly.
For Scrapy’s official docs, see Scrapy 2.11+ documentation. For Playwright’s, see Playwright Python documentation.
Installation
pip install scrapy scrapy-playwright playwright playwright install chromiumVerify versions:
scrapy version # 2.11.0 or later python -c "import scrapy_playwright; print(scrapy_playwright.__version__)" # 0.0.40 or laterProject setup
Create a Scrapy project:
scrapy startproject dynamic_scraper cd dynamic_scraperEdit
dynamic_scraper/settings.pyto enable scrapy-playwright:# settings.py DOWNLOAD_HANDLERS = { "http": "scrapy_playwright.handler.ScrapyPlaywrightDownloadHandler", "https": "scrapy_playwright.handler.ScrapyPlaywrightDownloadHandler", } TWISTED_REACTOR = "twisted.internet.asyncioreactor.AsyncioSelectorReactor" PLAYWRIGHT_BROWSER_TYPE = "chromium" PLAYWRIGHT_LAUNCH_OPTIONS = { "headless": True, "args": [ "--disable-blink-features=AutomationControlled", "--disable-features=IsolateOrigins", ], } # How many concurrent Playwright contexts to maintain PLAYWRIGHT_MAX_CONTEXTS = 8 PLAYWRIGHT_DEFAULT_NAVIGATION_TIMEOUT = 30000 # Reasonable defaults for mixed crawls CONCURRENT_REQUESTS = 16 CONCURRENT_REQUESTS_PER_DOMAIN = 4 DOWNLOAD_DELAY = 0 RETRY_TIMES = 3 # Standard middleware for header rotation, etc DOWNLOADER_MIDDLEWARES = { "scrapy.downloadermiddlewares.useragent.UserAgentMiddleware": None, "scrapy_user_agents.middlewares.RandomUserAgentMiddleware": 400, "dynamic_scraper.middlewares.ProxyMiddleware": 410, } # Item pipeline (whatever you need) ITEM_PIPELINES = { "dynamic_scraper.pipelines.JsonExportPipeline": 300, }The download handlers route every HTTP request through scrapy-playwright. Pages without the
playwrightmeta flag are still fetched via regular HTTP (scrapy-playwright detects this automatically), so you do not pay the browser cost for pages that do not need it.A first spider mixing HTML and Playwright requests
Most real spiders mix request types. The product listing might be static HTML, but the product detail might require JavaScript. scrapy-playwright handles this cleanly:
# spiders/products.py import scrapy from scrapy_playwright.page import PageMethod class ProductSpider(scrapy.Spider): name = "products" allowed_domains = ["example.com"] start_urls = ["https://example.com/products"] def parse(self, response): # Listing page is plain HTML, no Playwright needed for product_url in response.css("a.product-link::attr(href)").getall(): yield response.follow( product_url, callback=self.parse_product, meta={ "playwright": True, "playwright_include_page": False, "playwright_page_methods": [ PageMethod("wait_for_selector", "div.product-detail"), ], }, ) next_page = response.css("a.next::attr(href)").get() if next_page: yield response.follow(next_page, callback=self.parse) def parse_product(self, response): yield { "title": response.css("h1.product-title::text").get(), "price": response.css("span.price-current::text").get(), "stock": response.css("div.stock-info::text").get(), "description": response.css("div.product-description").get(), }Key points:
- The listing page is fetched normally (no
playwright: Truein meta) - Product detail pages are fetched via Playwright (
playwright: True) playwright_page_methodsruns on the Playwright page before returning the HTMLwait_for_selectorensures the JavaScript-rendered detail is present before scrapy-playwright captures the HTML
This pattern keeps the spider’s overall structure conventional while opting specific pages into the heavyweight rendering path.
Interacting with the page
Some scraping requires interaction (click a “Show more” button, scroll to load infinite content, fill a search form). For this, request the actual Playwright Page object back via
playwright_include_page=True:import scrapy from scrapy_playwright.page import PageMethod class InfiniteScrollSpider(scrapy.Spider): name = "infinite" start_urls = ["https://example.com/feed"] def start_requests(self): for url in self.start_urls: yield scrapy.Request( url, meta={ "playwright": True, "playwright_include_page": True, "playwright_page_methods": [ PageMethod("wait_for_selector", "article.feed-item"), ], }, callback=self.parse_feed, errback=self.errback_close_page, ) async def parse_feed(self, response): page = response.meta["playwright_page"] # Scroll to load more items for _ in range(5): await page.evaluate("window.scrollBy(0, document.body.scrollHeight)") await page.wait_for_timeout(2000) html = await page.content() await page.close() # Parse the now-loaded HTML with Scrapy selectors from scrapy.http import HtmlResponse new_response = HtmlResponse(url=response.url, body=html, encoding="utf-8") for article in new_response.css("article.feed-item"): yield { "title": article.css("h2::text").get(), "url": article.css("a::attr(href)").get(), "summary": article.css("p.summary::text").get(), } async def errback_close_page(self, failure): page = failure.request.meta.get("playwright_page") if page: await page.close()The
playwright_include_page=Truemeans the Page object is attached to the response. Your callback can interact with it (scroll, click, wait) before extracting HTML. Always close the page in your callback or errback to avoid leaking browser contexts.Proxy integration
Pass proxies to Playwright either at the launch level (one proxy for all contexts) or per-context (different proxy per request). Per-context is the more flexible pattern.
# settings.py addition PLAYWRIGHT_CONTEXTS = { "default": { "proxy": { "server": "http://squid.internal:3128", "username": "scraper_user", "password": "secret", }, }, }For per-request proxies via a custom middleware:
# middlewares.py import random class ProxyRotationMiddleware: PROXIES = [ "http://user1:pass1@proxy1.provider.com:8080", "http://user2:pass2@proxy2.provider.com:8080", "http://user3:pass3@proxy3.provider.com:8080", ] def process_request(self, request, spider): if request.meta.get("playwright"): # For Playwright requests, set the context with a specific proxy proxy = random.choice(self.PROXIES) user_pass, host_port = proxy.split("//")[1].split("@") user, password = user_pass.split(":") host, port = host_port.split(":") request.meta["playwright_context"] = f"proxy_{hash(proxy)}" request.meta["playwright_context_kwargs"] = { "proxy": { "server": f"http://{host}:{port}", "username": user, "password": password, }, } else: # For regular HTTP requests, set the standard proxy meta request.meta["proxy"] = random.choice(self.PROXIES)scrapy-playwright caches contexts by name, so reusing the same proxy uses the same context. This avoids spawning a new browser context per request, which is expensive.
Stealth: integrating patchright
For sites that fingerprint, use patchright (Playwright stealth fork) instead of vanilla Playwright. patchright is a drop-in replacement, so you can swap it in via setting the playwright module:
# In your settings.py or as an environment variable import patchright as playwright_module PLAYWRIGHT_BROWSER_TYPE = "chromium" # scrapy-playwright will use whatever playwright module is installedOr install patchright as the playwright package (it shadows vanilla playwright):
pip install patchright playwright install chromiumAfter install, scrapy-playwright automatically uses patchright’s launch logic, which applies stealth patches.
For fine-grained stealth on a per-request basis, inject scripts via
init_script:yield scrapy.Request( url, meta={ "playwright": True, "playwright_context_kwargs": { "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", }, "playwright_page_init_script": """ Object.defineProperty(navigator, 'webdriver', { get: () => undefined }); Object.defineProperty(navigator, 'plugins', { get: () => [1, 2, 3] }); """, }, )For the full picture on browser fingerprinting, see canvas fingerprinting bypass and WebGL fingerprinting bypass.
Performance: how much does Playwright slow you down
Throughput comparison on a single worker:
request type requests/sec pure Scrapy HTML 200-500 scrapy-playwright (cached context, simple page) 5-10 scrapy-playwright (new context, complex SPA) 1-2 scrapy-playwright with full humanization 0.3-0.5 The cost is real. Use Playwright only for pages that need it. The mixed pattern (HTML for listings, Playwright for details) is the right balance for most sites.
To scale Playwright throughput:
- Increase
PLAYWRIGHT_MAX_CONTEXTS(each context costs ~100 MB RAM) - Reuse contexts across requests by giving them stable names
- Run multiple Scrapy instances on different machines
- Keep navigation timeout tight (30 seconds is usually enough)
Comparison: scrapy-playwright vs alternatives
approach strengths weaknesses scrapy-playwright mixed HTML/JS in one project, native Scrapy pipelines adds Playwright overhead even for simple sites scrapy-splash older, lighter than Playwright Splash is essentially abandoned, JS engine is dated scrapy + selenium-wire works slow, fragile scrapy + standalone Playwright service clean separation extra service to operate pure Playwright most JS-capable no Scrapy pipelines, write your own everything Crawlee (Node) similar pattern in Node ecosystem requires switching off Python For most Python teams in 2026, scrapy-playwright is the right pick. For Node teams, Crawlee. For very simple JS sites, scrapy-splash still works but is on its way out.
For the alternative Crawlee path, see Scrapy Cloud vs Crawlee Cloud in 2026.
Item pipelines and Playwright
Item pipelines work identically whether the request was HTML-only or Playwright. The pipeline sees a Scrapy
Item(or dict) and processes it. No changes needed:# pipelines.py import json from datetime import datetime class JsonExportPipeline: def open_spider(self, spider): self.file = open(f"output/{spider.name}_{datetime.now():%Y%m%d_%H%M%S}.jsonl", "w") def close_spider(self, spider): self.file.close() def process_item(self, item, spider): line = json.dumps(dict(item)) + "\n" self.file.write(line) return itemThis is one of the main wins of scrapy-playwright over rolling your own: you get to keep all your existing Scrapy pipelines and middleware.
Common patterns
Wait for specific elements before extracting:
yield scrapy.Request( url, meta={ "playwright": True, "playwright_page_methods": [ PageMethod("wait_for_selector", "div.product-loaded"), PageMethod("wait_for_load_state", "networkidle"), ], }, )Click a button to load content:
yield scrapy.Request( url, meta={ "playwright": True, "playwright_page_methods": [ PageMethod("click", "button.show-more"), PageMethod("wait_for_selector", "div.expanded-content"), ], }, )Take a screenshot for debugging:
yield scrapy.Request( url, meta={ "playwright": True, "playwright_page_methods": [ PageMethod("screenshot", path="debug.png", full_page=True), ], }, )Intercept network requests:
async def parse(self, response): page = response.meta["playwright_page"] # Capture all XHR responses captured_data = [] page.on("response", lambda r: captured_data.append(r.url)) await page.wait_for_load_state("networkidle") await page.close() yield {"all_xhr_urls": captured_data}Production deployment
A production scrapy-playwright deployment looks like:
- Compute: container with Chromium installed (use playwright-base images)
- Concurrency: tune CONCURRENT_REQUESTS based on RAM (each Playwright context is ~100 MB)
- Proxy: routed through Squid or commercial pool
- Stealth: patchright as drop-in
- Monitoring: Scrapy stats + Playwright context counter
- Persistence: items to a database or queue, not just files
- Scheduling: scrapyd, scrapy-cluster, or Airflow
A reasonable container Dockerfile:
FROM mcr.microsoft.com/playwright/python:v1.45.0-jammy WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright CMD ["scrapy", "crawl", "products"]Use the playwright-python base image to skip installing Chromium and dependencies separately.
Operational checklist
For production scrapy-playwright deployments in 2026:
- Scrapy 2.11+, scrapy-playwright 0.0.40+
- Use playwright-python base image for containers
- patchright for stealth
- Per-request opt-in to Playwright via meta flag
- Always close pages in callbacks and errbacks
- Use named contexts to reuse across requests
- Tune CONCURRENT_REQUESTS based on RAM
- Set navigation timeouts (30s default is reasonable)
- Log per-spider stats including Playwright vs HTML request count
- Pair with proxy rotation middleware
- Monitor RAM usage closely (Playwright contexts leak if not closed)
FAQ
Q: can I use Playwright with Scrapy without scrapy-playwright?
You can, but it is painful. You would have to write your own download handler, manage the browser lifecycle, and integrate with Scrapy’s async model. scrapy-playwright handles all this for you.Q: what about scrapy-splash?
Splash is essentially unmaintained as of 2024-2025. The JS engine is older and many modern SPAs do not render correctly. Use scrapy-playwright unless you have legacy reasons to stay on Splash.Q: how do I handle Playwright authentication and cookies?
Pass cookies via the request meta or set them on the context:meta={"playwright": True, "playwright_context_kwargs": {"storage_state": "auth.json"}}auth.jsonis generated by saving Playwright’scontext.storage_state()after login.Q: can I run scrapy-playwright on AWS Lambda?
Technically yes via Playwright’s Lambda layers, but the cold start is brutal (10+ seconds per invocation just to launch Chromium). For Lambda, prefer dedicated browser services like Browserbase or run scrapy-playwright on Fargate or EKS instead.Q: does scrapy-playwright support Firefox?
Yes. SetPLAYWRIGHT_BROWSER_TYPE = "firefox"and runplaywright install firefox. Most patches work on Firefox too, though some stealth libraries are Chromium-focused.Common pitfalls in production scrapy-playwright
The first failure mode that catches teams off guard is the page-object leak. scrapy-playwright passes the open
Pageto your callback viaresponse.meta["playwright_page"], but if your parse function raises an exception or yields requests without closing the page, the browser context retains a reference. After 50-100 leaked pages, Chromium hits its per-context limit (typically 30 tabs depending on RAM) and new requests stall waiting for tab slots. The fix is a try/finally pattern in every parse method that touchesplaywright_page:async def parse_product(self, response): page = response.meta.get("playwright_page") try: await page.wait_for_selector(".product-loaded", timeout=10000) title = await page.text_content("h1") yield {"title": title} except Exception as e: self.logger.error(f"parse failed: {e}") raise finally: if page: await page.close()Add an errback that also closes the page on download failure, otherwise pages leak from network errors that never reach your parse callback.
The second pitfall is the headers-vs-Playwright disconnect. Setting
headerson ascrapy.Requestdoes not automatically pass them to Playwright whenplaywright=True. Scrapy treats those headers as the eventual download request, but Playwright bypasses Scrapy’s downloader entirely and uses its own. The result: your User-Agent rotation middleware appears to set a Chrome 124 UA but Playwright launches with whatever UA the browser instance has. The fix is to set headers viaplaywright_context_kwargson each request:yield scrapy.Request( url, meta={ "playwright": True, "playwright_context_kwargs": { "user_agent": rotated_ua, "extra_http_headers": rotated_headers, }, }, )Each new context spawn now gets the rotated UA and headers. Beware: spawning a new context per request defeats Playwright’s connection reuse, so pair this with named contexts to amortize the cost across same-UA requests.
The third pitfall is silent JavaScript navigation that bypasses Scrapy’s URL tracking. When a page does
window.location.href = "/next-page"mid-load, Playwright follows it and ends up at a URL different from what you originally requested. Scrapy’s response.url reflects the post-navigation URL, but your duplicate-detection middleware only saw the original URL. You can end up scraping the same final page repeatedly through different entry-point URLs. The fix is to track final URLs in your DupeFilter:from scrapy.dupefilters import RFPDupeFilter import hashlib class FinalUrlDupeFilter(RFPDupeFilter): def request_seen(self, request): # If response has been received, dedupe on response.url too final_url = request.meta.get("playwright_final_url") if final_url: fp = hashlib.sha256(final_url.encode()).hexdigest() if fp in self.fingerprints: return True self.fingerprints.add(fp) return super().request_seen(request)Set
playwright_final_urlin your parse callback after capturingpage.url, then re-yield the request through the dedupe filter.Real-world example: 50,000-product crawl with mixed HTML and Playwright
A scrapy-playwright project crawled an electronics retailer with 50,000 products across 800 category pages. Initial implementation used Playwright for everything and took 18 hours per full crawl with 90 percent CPU usage on a 16-core machine. The optimization that cut this to 2.5 hours was a tiered request strategy that classified URL patterns and used the cheapest method per pattern:
class TieredSpider(scrapy.Spider): name = "products_tiered" def start_requests(self): # Category pages: pure HTML, server-rendered for cat_url in self.category_urls: yield scrapy.Request(cat_url, callback=self.parse_category) def parse_category(self, response): # Extract product URLs from server-rendered HTML for url in response.css(".product-link::attr(href)").getall(): full_url = response.urljoin(url) # Tier 1: standard product page (HTML works) if "/p/" in full_url: yield scrapy.Request(full_url, callback=self.parse_product_html) # Tier 2: SPA product detail (Playwright needed) elif "/spa-product/" in full_url: yield scrapy.Request( full_url, meta={ "playwright": True, "playwright_page_methods": [ PageMethod("wait_for_selector", ".price-loaded"), ], }, callback=self.parse_product_playwright, ) def parse_product_html(self, response): yield { "url": response.url, "title": response.css("h1::text").get(), "price": response.css(".price::text").get(), } async def parse_product_playwright(self, response): page = response.meta["playwright_page"] try: title = await page.text_content("h1") price = await page.text_content(".price") yield {"url": response.url, "title": title, "price": price} finally: await page.close()Of the 50,000 products, only 8,000 needed Playwright (the SPA product pages). The remaining 42,000 went through pure Scrapy HTML at 200+ requests/second. Playwright handled the 8,000 SPA pages at 5 requests/second. Total throughput: 5.5 hours for the HTML tier and 27 minutes for the SPA tier, running concurrently in 2.5 hours total. The lesson: classify your URLs by render type and route accordingly. Default-to-Playwright is the most expensive choice you can make.
Comparison: scrapy-playwright settings tuning by site type
Different site types need different tuning profiles. A reference table from a 2026 production deployment:
site type CONCURRENT_REQUESTS PLAYWRIGHT_MAX_CONTEXTS nav timeout wait strategy ecommerce SPA 16 8 30s wait_for_selector on price news article 32 16 15s wait_for_load_state domcontentloaded listing with infinite scroll 8 4 60s manual scroll + wait_for_response dashboard behind login 4 2 45s wait_for_url + wait_for_selector pricing page (heavy JS) 16 8 30s wait_for_function on data-loaded attr For listing pages with infinite scroll, use a manual scroll loop:
async def scroll_until_done(page, max_scrolls=20): for i in range(max_scrolls): prev_height = await page.evaluate("document.body.scrollHeight") await page.evaluate("window.scrollTo(0, document.body.scrollHeight)") await page.wait_for_timeout(1500) new_height = await page.evaluate("document.body.scrollHeight") if new_height == prev_height: break # no more content loadedWithout the scroll-until-done pattern, you get only the first viewport’s worth of items even when the listing has 500+. Pair with a per-spider
MAX_SCROLL_ITERATIONSsetting so a runaway page does not loop forever.Memory profiling: detecting Playwright leaks
Production scrapy-playwright deployments suffer from RAM growth that takes hours to manifest. The cause is almost always context reuse without bounded lifetime. Each long-lived context accumulates DOM nodes, IndexedDB entries, and service worker state. After 4-6 hours of constant scraping through one context, RAM usage doubles. The detection pattern:
import psutil import os class MemoryMonitorMiddleware: def __init__(self): self.process = psutil.Process(os.getpid()) self.baseline_mb = self.process.memory_info().rss / 1024 / 1024 self.requests_processed = 0 def process_response(self, request, response, spider): self.requests_processed += 1 if self.requests_processed % 100 == 0: current_mb = self.process.memory_info().rss / 1024 / 1024 growth = current_mb - self.baseline_mb spider.logger.info( f"requests={self.requests_processed} " f"rss={current_mb:.0f}MB growth={growth:.0f}MB" ) if growth > 2000: # 2GB growth, recycle contexts spider.logger.warning("memory bloat detected, recycling contexts") # Trigger context recycling in your context manager return responseRecycle contexts every 1000-2000 requests by closing them and letting scrapy-playwright spawn fresh ones. This caps RAM growth at the cost of one cold-context launch per recycle (about 800ms). For long-running spiders, this is a worthwhile trade.
Wrapping up
Scrapy + Playwright is the right combination for any Python scraping project that mixes simple HTML pages with JavaScript-heavy pages. scrapy-playwright glues them cleanly enough that you keep all of Scrapy’s pipeline ergonomics while getting Playwright’s rendering capability where you need it. Pair this guide with our Scrapy Cloud vs Crawlee Cloud and best Python scraping libraries 2026 writeups, and browse the framework-tutorials category on DRT for related tutorials.
- The listing page is fetched normally (no
-
Self-hosted proxy infrastructure: complete 2026 guide
Self-hosted proxy infrastructure: complete 2026 guide
Self-hosted proxy infrastructure is a category that ranges from “I run Squid in front of five purchased upstream proxies” to “I operate a 200-modem mobile proxy farm with ISP business contracts and four-9s uptime.” This guide is about the latter end of the spectrum: the build that actually saves money compared to buying from Bright Data or Oxylabs at scale, the operational reality of running it, and the break-even point where self-hosting starts paying off.
The numbers in this guide come from operating real proxy infrastructure on the SMP (Singapore Mobile Proxy) and similar farms in the Singapore-Malaysia region. The pricing, hardware choices, and ISP dynamics are 2026 reality. If you are scraping under 1 million pages per month, self-hosting is probably not worth it. Above 10 million, the unit economics are compelling. In between is the gray zone where it depends on your engineering capacity.
Why self-host
The case for self-hosted proxies in 2026:
- Cost: at scale, self-hosted mobile proxies cost $0.30-$1 per GB versus $5-15/GB from premium vendors
- Control: you choose the ISPs, IP ranges, geo distribution, and rotation behavior
- Reliability: no upstream vendor outages affecting your business
- Privacy: your traffic does not pass through a third-party gateway that logs everything
- Customer billing: you can resell capacity if you have spare throughput
The case against:
- Capex: hardware, racks, network equipment cost upfront ($50k+ for a serious build)
- Opex: data plans, electricity, internet uplink, maintenance hours
- ISP relationships: requires business contracts, sometimes legal entity in the country
- 24/7 ops: things break at 3am, you fix them
- Capacity inflexibility: cannot quickly add 500 more IPs the way Bright Data can
Most teams should buy proxies. Self-hosting is for the small number of teams whose volume justifies the operational burden.
Architecture: the SMP-style mobile proxy farm
A typical mid-scale mobile proxy farm:
+ customers (scrapers, ad-verification, OSINT) + | | HTTPS to public IP v +-------------------------+ | Edge proxy (Squid + | | ProxySmart controller) | +-------------------------+ | | private 10.x routing v +-------+-------+-------+-------+ | | | | | server1 server2 server3 server4 ... | | | | | | USB | USB | USB | USB | v v v v v modem1 modem2 modem3 modem4 modem5 ... (LTE) (LTE) (LTE) (LTE) (LTE) | | | | | v v v v v ISP A ISP B ISP A ISP C ISP B (carrier APNs)Each modem produces one mobile IP via PPP-over-LTE through the carrier’s APN. The server hosts a process that exposes that modem as an HTTP/SOCKS5 port to the controller. The controller assigns ports to customers, handles rotation, and meters usage.
This pattern scales modularly: add a server with 50 modems, expose 50 more ports, customers see them as additional capacity. Each modem is one IP with full carrier-grade NAT obfuscation, which is the reason mobile IPs are valuable for scraping (they look like millions of real users sharing the carrier’s NAT pool).
Hardware: per-server build
Per-server hardware for a 50-modem build:
component spec purpose chassis 4U server, 12-bay or rackmount mining frame physical housing CPU Intel i5-12500 or Ryzen 5 5600 enough for 50 modem PPP daemons RAM 32 GB DDR4 one PPP daemon per modem, ~500 MB each storage 500 GB NVMe SSD OS, logs, modem state USB hubs 4x 16-port industrial USB 3.0 hubs 64 USB ports total modems 50x Huawei E3372 or similar 4G/LTE dongles the actual modems SIM cards 50x carrier SIMs with data plans network access network 2x 1 Gbit Ethernet (LACP) uplink to switch PSU redundant 850W reliability Cost per server: roughly $4,000-6,000 in hardware plus $300-500/month in SIM data plans (varies by country and plan size).
For the full multi-server build:
- 4-server cluster: ~$20,000 hardware + $1,500/month opex
- Yields 200 mobile IPs
- Throughput: ~10 Mbit/s per modem aggregate, so 2 Gbit/s peak across the farm
- Bandwidth budget: usually 50-200 GB per modem per month (carrier-dependent)
Modems are the failure-prone component. Plan for 5-10% modem replacement per year. Keep spare modems and SIMs on hand.
Software stack
Each server runs:
- Linux: Ubuntu 22.04 LTS or 24.04 LTS (most modem driver compatibility)
- PPP daemon: pppd per modem with carrier-specific APN config
- ProxySmart: per-modem HTTP/SOCKS proxy daemon exposing the PPP IP as a port
- Controller agent: communicates with the central controller for port assignment, rotation, telemetry
ProxySmart specifically wraps each modem’s PPP interface and exposes it as an HTTP CONNECT proxy. Customers connect to a port (e.g.,
server2.example.com:8123) and traffic egresses through the modem’s mobile IP.For DNS within the proxy farm, run a local resolver (unbound or dnsmasq) so that DNS queries do not leak to ISP resolvers, which can throttle or log them.
Network: ISP business contracts
The hardest part of self-hosting mobile proxies is not the technology, it is the SIM card supply. Personal-line carriers throttle or terminate SIMs that are clearly used for proxying. You need:
- Business SIM contracts: a corporate account with 50+ lines, possibly negotiated bulk pricing
- Data plans: typically 100-200 GB/month per line, billed as a corporate package
- APN access: business APN (sometimes different from consumer APN)
- Rotating contract terms: some carriers prohibit “automation” in their AUP; you need terms that allow this use
Country-specific dynamics in 2026:
region ISP-friendliness typical contract Singapore high (Singtel, Starhub, M1 all have business plans) $30-50 per 100 GB Malaysia medium (Maxis, Digi, Celcom; some restrictions) $20-40 per 100 GB Indonesia medium (Telkomsel, Indosat; data caps strict) $15-30 per 100 GB Thailand high (AIS, Truemove, dtac all have plans) $25-45 per 100 GB US low (Verizon, AT&T, T-Mobile have hostile AUPs) $40-80 per 100 GB Europe varies (Vodafone, Orange friendlier; Telekom hostile) $20-50 per 100 GB Without a business relationship, the SIMs get shut down within weeks of heavy use. With a relationship, they last months or years.
Carrier rotation: rebooting modems for fresh IPs
Mobile IPs rotate on PPP reconnection. To get a new IP:
- Send AT command to modem to reset
- Wait for PPP to drop and reestablish
- New IP is assigned by the carrier
Time to rotate: 10-30 seconds per modem. Customers see this as a “rotate” API call that returns a new IP within 30 seconds.
# Sketch of modem rotation script import subprocess import time def rotate_modem(modem_id: int) -> str: # Send AT command via the modem's serial interface subprocess.run( ["echo", "AT+CFUN=4", ">", f"/dev/ttyUSB{modem_id}_at"], shell=True, ) time.sleep(2) subprocess.run( ["echo", "AT+CFUN=1", ">", f"/dev/ttyUSB{modem_id}_at"], shell=True, ) # Wait for PPP to come back up for _ in range(30): time.sleep(1) ip = subprocess.check_output( ["ip", "addr", "show", f"ppp{modem_id}"] ).decode() if "inet " in ip: new_ip = ip.split("inet ")[1].split("/")[0] return new_ip raise TimeoutError(f"modem {modem_id} did not reconnect")In production, ProxySmart handles this via a REST API exposed per modem. Customers call
POST /rotate/<port_token>and get a fresh IP within 30 seconds.Customer-facing API
A typical customer interaction:
# Customer's scraper uses the proxy port curl -x http://user:pass@proxy.example.com:8123 https://target.example.com # When they need rotation curl -X POST -H "Authorization: Bearer api_key" \ https://proxy.example.com/api/v1/rotate/port_8123 # Check current IP curl -x http://user:pass@proxy.example.com:8123 https://api.ipify.orgThe controller exposes a REST API for customer self-service:
POST /api/v1/rotate/<port>: trigger rotationGET /api/v1/status/<port>: current IP, data used, plan limitPOST /api/v1/swap/<port>: swap to a different modem (different ISP/IP range)GET /api/v1/usage: per-customer billing info
For an example of this kind of platform, see Singapore Mobile Proxy which exposes mobile proxies via this pattern.
Costs and break-even
Real numbers for a 200-modem mobile farm in Singapore:
line item monthly cost SIM data plans (200 lines, 100 GB each) $7,000 Server power (4 servers, 24/7) $400 Internet uplink (1 Gbit business) $300 Rack space (colo, 2U each * 4) $500 Hardware amortization ($20k over 36 months) $560 Engineering time (1 day/week) $2,000 Total $10,760/month Capacity: 200 IPs * 100 GB/month = 20,000 GB/month total bandwidth. At a customer price of $1.50/GB, revenue is $30,000/month at full utilization. Margin: 65% gross.
For comparison, buying 20,000 GB of mobile proxy from Bright Data at $8/GB costs $160,000/month. Self-hosting saves $150k/month at this scale.
Break-even: roughly 30 modems / 3,000 GB/month is the point where self-hosting beats buying. Below that, the engineering and ops overhead is not worth the savings.
Monitoring
Critical metrics:
- Per-modem PPP uptime: alert when any modem is down >5 minutes
- Per-modem data usage: track against carrier plan limits
- Per-port latency: median and p95 from edge proxy
- Per-port throughput: bytes/sec by direction
- Carrier IP reputation: periodically check IPs against known-bot lists
- Modem hardware health: temperature, USB hub status
Tooling:
- Prometheus + node_exporter on each server
- Custom exporter for modem state (PPP uptime, IP, data usage)
- Grafana dashboards for operator view
- AlertManager routing to PagerDuty or Telegram
Sample dashboard panels:
panel query modem uptime sum(up{job=”modem”}) / count(up{job=”modem”}) data usage by modem sum_over_time(modem_bytes_total[7d]) latency p95 by port histogram_quantile(0.95, port_latency_seconds_bucket) customer requests/sec rate(controller_requests_total[1m]) Failure modes
Real failure modes from running this kind of farm:
- USB hub disconnect: a 16-port hub stops responding, takes down 16 modems. Fix: replace hub or USB-reset.
- Modem firmware crash: single modem stops responding. Fix: USB-reset or physical reboot.
- PPP daemon hang: ppp0 interface up but no traffic. Fix: kill pppd, restart.
- Carrier IP block: ISP blocks one of your IP ranges. Fix: rotate to different APN or wait.
- SIM data plan exhausted: SIM stops working. Fix: switch to backup SIM or wait for plan reset.
- DNS poisoning by carrier: ISP injects DNS responses. Fix: use local resolver via DoT/DoH to bypass ISP.
- Server crash: takes down all modems on that server. Fix: redundancy, multiple servers.
- Network uplink flap: brief outages drop customer connections. Fix: dual uplinks with LACP.
The annual maintenance burden is roughly 100-200 engineering hours per 200-modem farm.
Comparison: self-host vs commercial
dimension self-hosted commercial cost per GB $0.30-1 at scale $5-15 scale time weeks to add capacity minutes reliability 99-99.9% (you own it) 99.9-99.99% (vendor SLA) geo coverage one country usually global IP rotation per modem, your control massive pools, vendor control customer trust depends on your reputation well-known brand ops burden high zero break-even ~3,000 GB/month starts at zero Self-host when: high volume in one geo, regulatory needs to keep traffic local, custom rotation requirements, or building a product.
Commercial when: low volume, multi-geo needed, no engineering capacity for ops, want zero burden.
For broader proxy strategy, see best mobile proxy providers 2026 and building a custom rotating proxy pool with Squid.
Legal and compliance
Operating proxy infrastructure has compliance touchpoints:
- Local telecoms law: in some countries, providing internet access requires a license
- Customer KYC: depending on jurisdiction, you may need to verify customer identity
- Logging requirements: some countries require ISPs to retain connection logs
- AUP enforcement: customer abuse (CSAM, fraud) can implicate you if not addressed
- Tax: B2B services have VAT/GST in many jurisdictions
For OWASP guidance on related security topics, see OWASP Top 10. For the specifics of running a regulated telecom service in your country, consult local counsel.
Operational checklist
For running a self-hosted mobile proxy farm in 2026:
- Hardware sized for 50-200 modems per server
- Business SIM contracts with carriers
- Local DNS resolver (unbound)
- Per-modem PPP daemons + ProxySmart
- Central controller for port assignment
- Customer REST API
- Prometheus + Grafana monitoring
- 24/7 alerting on PPP uptime and data usage
- Spare modems on hand (10% of fleet)
- Regular IP reputation checks against deny lists
- Customer billing system (Stripe, BMS, custom)
- Legal review of AUP and terms
Smaller-scale alternative: residential at home
If you cannot justify mobile farm capex, a smaller residential setup at scale:
- Get 5-10 residential ISP lines at home or in a colo with multiple ISPs
- Run Squid or HAProxy on a single machine routing across the lines
- Each line gives you one residential IP
- Total cost: $200-500/month for the lines plus colo
Capacity is small (maybe 1-10 GB/day per line) but the IPs are real residential, very high quality. Useful for low-volume high-value scraping (account creation, captcha solving) where IP quality matters more than capacity.
FAQ
Q: how long do mobile SIMs last before getting flagged or shut down?
With a business contract: months to years. Without one: weeks. The relationship with the carrier is the durability factor, not the technology.Q: can I run this on AWS or GCP?
Mobile proxies require physical USB-connected modems, so no. You can run the controller and edge proxy in cloud, but the modems themselves need physical hosting (your office, a colo, or a residential location).Q: what about IPv6?
Most carriers in 2026 are dual-stack. ProxySmart supports both. For target sites that prefer IPv4 (most do), force IPv4 routing. For target sites with IPv6, expose the v6 address.Q: is self-hosting worth it for residential proxies (not mobile)?
Less so. Residential ISP lines are slower to provision, harder to multi-tenant, and have hostile AUPs. Mobile is the better fit for self-hosting because of carrier-grade NAT and the established business-line market.Q: how do I price for customers?
Common patterns: per-GB ($1-3/GB), per-port-month ($30-100 per dedicated port), or hybrid. Mobile proxies command higher prices than datacenter or residential. Match competitor pricing in your geo and undercut by 20% to get traction.Common pitfalls in production self-hosted proxy infrastructure
The first failure mode is USB hub contention. Mobile proxy farms typically connect 16-32 USB modems per host through powered USB 3.0 hubs. Cheap hubs (under $40) commonly share one USB controller across all 16 ports, which means total throughput across the hub caps at roughly 480 Mbit/s shared. With 16 LTE modems each capable of 50-100 Mbit/s, the hub bandwidth becomes the bottleneck before the modems do. The fix is per-port-controlled hubs (Yepkit YKUSH3 or VIA-chipset hubs from D-Link DUB-H7 generation) where each port has its own USB controller chain. Budget $80-120 per 7-port hub. Verify with
lsusb -tthat each modem reports under a separate Bus rather than a shared one.The second pitfall is carrier-side IP rotation policy mismatch. Different carriers rotate the LTE-assigned IP at different intervals: Singtel rotates every 4 hours by default, M1 rotates every 24 hours, StarHub rotates only on PDP context teardown (so it can stay sticky for days). If your scraper expects a stable IP per session and you provision a Singtel modem, your customer’s session token bound to the original IP becomes invalid after 4 hours. Match carrier choice to use case: use StarHub for sticky sessions, use Singtel for high-rotation requirements. Document each modem’s carrier in your inventory and route customer traffic accordingly.
The third pitfall is modem firmware silent regression. Quectel and Fibocom modems push firmware updates over the air for some operators. A firmware bump from EM05CEFAR06A07M4G to EM05CEFAR07A02M4G can change the AT command set, the PPP negotiation timing, or the TLS-handshake-passthrough behavior. Your provisioning scripts that worked yesterday produce silent failures today. The mitigation is firmware pinning where the modem allows it (
AT+QFOTADL=0to disable Quectel OTA on Quectel EM05/EM06/EC25 modems), or a weekly automated firmware-version inventory that flags any modem whoseAT+CGMRoutput changed since last week.Real-world example: month-end IP exhaustion recovery
A self-hosted mobile farm running 60 modems across three Singapore carriers hit a customer-facing outage on month-end of February 2026. Sixteen Vivifi modems exhausted their 100GB monthly data caps within the first 12 hours of February 28 because three large customers had over-rotated through the same proxy pool. Carrier behavior on cap exhaustion: the modem still attaches, PPP comes up, but every TCP connection times out or returns 503 from a carrier portal page. The pool’s automated health checks reported the modems “online” because PPP was up, but real customer traffic was 100 percent failing.
The recovery had three steps:
# Step 1: detect data exhaustion via active probe (not just PPP state) async def is_modem_alive(port: int) -> bool: try: resp = await fetch_via_proxy( f"http://192.168.1.1:{port}", target_url="https://1.1.1.1/cdn-cgi/trace", timeout=5, ) return resp.status == 200 and "ip=" in resp.text except Exception: return False # Step 2: auto-rotate exhausted modems out of the active pool exhausted = [m for m in MODEMS if not await is_modem_alive(m.port)] for modem in exhausted: await mark_modem_unavailable(modem) # Step 3: rotate to LTESpace reserve SIMs from the spare inventory reserve = await get_reserve_sims(carrier="vivifi", min_data_gb=80) for spare_sim, dead_modem in zip(reserve, exhausted): await swap_sim(dead_modem.imei, spare_sim.iccid)The recovery took 47 minutes from first alert to full pool restoration. Lessons: PPP-state monitoring is necessary but not sufficient (must also probe an active TCP destination), reserve SIM inventory needs to be maintained at 20 percent of fleet size to absorb month-end exhaustion spikes, and customer billing should track per-port data usage daily so heavy users can be throttled before they exhaust shared pool capacity.
Wrapping up
Self-hosted proxy infrastructure pays off above ~3,000 GB/month if you have engineering capacity for ops. Below that threshold, buying from commercial vendors is the right call. For teams that build into self-hosting, the unit economics are very good: 65% gross margins, durable competitive advantage, customer trust through ownership of the stack. Pair this with our building a custom rotating proxy pool with Squid and best mobile proxy providers 2026 guides for both the build-vs-buy decision and the technical implementation, and browse the dev-tools-projects category on DRT for related infrastructure deep-dives.
Related comparison: See how Bright Data stacks up against a dedicated Singapore mobile network in our Singapore Mobile Proxy vs Bright Data comparison.
-
Building a custom rotating proxy pool with Squid in 2026
Building a custom rotating proxy pool with Squid in 2026
Building a custom rotating proxy pool with Squid is one of the cheapest ways to gate egress traffic across a fleet of upstream proxies without paying for a premium gateway. You buy 50 residential or mobile proxies from various providers, point Squid at all of them as parent peers, expose a single internal port to your scraping fleet, and let Squid rotate which upstream takes each request. Done correctly, this gives you per-customer rate limiting, per-target proxy affinity, automatic failover when an upstream dies, and the ability to drop in or remove upstream peers without touching your scraper code.
This guide walks through Squid 6.x on Ubuntu 22.04, the upstream peer configuration that does rotation and failover, ACLs that keep your pool from being abused, monitoring that actually works, and a Python client that integrates cleanly. Every config snippet is production-tested and the Squid ACL choices reflect lessons from running scraper farms that survived three years of growth.
Why Squid for proxy pooling
Squid is overkill for many use cases but well-suited for proxy pooling specifically because:
- It supports multiple parent peers with rotation and failover
- ACL system is mature and powerful
- Logging is verbose enough for forensic debugging
- Performance is excellent (handles 10k+ requests/second on modest hardware)
- Free and battle-tested since 1996
- Well-documented, with the official wiki covering most edge cases
What Squid does not do well: SOCKS5 forwarding (Squid is HTTP-first), per-request authentication to different upstreams, or websocket forwarding. For SOCKS5 use 3proxy or a custom Go service. For websockets use a forward proxy designed for it.
Architecture overview
scrapers (multiple servers) | | HTTP CONNECT / GET on internal port v +-----------+ | Squid | <- rotates across upstream peers +-----------+ | +-----+-----+--------+----------+ | | | | | ISP1 ISP2 mobile1 residential ... (upstream proxies) | | | | +--> internetScrapers connect to one Squid endpoint (port 3128 by default). Squid picks an upstream peer per request, forwards through it, and returns the response. Scrapers see a stable single endpoint while Squid handles rotation and failover internally.
Installing Squid 6.x on Ubuntu
sudo apt-get update sudo apt-get install -y squid # Verify version (should be 6.x in 2026 on Ubuntu 22.04 with backports or 24.04 baseline) squid -v | head -1For the latest Squid features, especially TLS interception and improved peer health checks, use Ubuntu 24.04 or build from source.
Base configuration
Replace
/etc/squid/squid.confwith this base. We will layer on parent peers and ACLs after.# /etc/squid/squid.conf — base scraper proxy pool http_port 3128 # Logging access_log /var/log/squid/access.log squid cache_log /var/log/squid/cache.log # Cache disabled (this is a forward proxy pool, not an HTTP cache) cache deny all cache_store_log none # DNS dns_v4_first on positive_dns_ttl 1 hour negative_dns_ttl 1 minute # Connection limits client_lifetime 1 hour read_timeout 60 seconds connect_timeout 30 seconds request_timeout 30 seconds # Header forwarding hygiene (do not leak client IP via X-Forwarded-For) forwarded_for delete via off # Reject CONNECT to non-standard ports acl SSL_ports port 443 acl Safe_ports port 80 acl Safe_ports port 443 acl CONNECT method CONNECT http_access deny CONNECT !SSL_ports http_access deny !Safe_portsThe key choices: cache disabled (we are forwarding, not caching),
forwarded_for delete(do not leak client IPs to upstreams),via off(do not advertise Squid in headers).Adding upstream peers
Each upstream proxy is a
cache_peerdirective. The example below adds five parent peers with round-robin selection and per-peer authentication.# /etc/squid/squid.conf — parent peer pool # Parent peers: format is host port http_port options cache_peer proxy1.provider.com parent 8080 0 round-robin no-query \ login=user1:pass1 name=peer_1 cache_peer proxy2.provider.com parent 8080 0 round-robin no-query \ login=user2:pass2 name=peer_2 cache_peer proxy3.provider.com parent 8080 0 round-robin no-query \ login=user3:pass3 name=peer_3 cache_peer proxy4.provider.com parent 8080 0 round-robin no-query \ login=user4:pass4 name=peer_4 cache_peer proxy5.provider.com parent 8080 0 round-robin no-query \ login=user5:pass5 name=peer_5 # Force all traffic through parent peers (never go direct) never_direct allow allOptions explained:
parent: this is an upstream HTTP proxy8080: port to connect to on upstream0: ICP port (we set to 0 because no ICP)round-robin: rotate across peers with this option in round-robinno-query: do not send ICP querieslogin=user:pass: HTTP Basic auth credentials for the upstreamname=peer_N: human-readable name for logs and metrics
The
never_direct allow allline is critical. Without it, Squid falls back to direct connection if all peers are down, which leaks your origin IP. With it, requests fail when peers are exhausted, which is the safer behavior for scrapers.Reload and verify
sudo squid -k parse # syntax check sudo systemctl reload squid # Test by curling through it curl -x http://localhost:3128 https://httpbin.org/ip # Expected: an IP from one of your upstream pools, not your originIf you see your origin IP,
never_directis not working. Check that the line is present and Squid has been reloaded.Rotation strategies
Squid supports several peer selection strategies via cache_peer options:
option behavior round-robineach request goes to next peer in order weighted-round-robinround-robin with per-peer weight carpconsistent hash by URL (same URL always goes to same peer) userhashconsistent hash by client IP sourcehashconsistent hash by client IP and port For most scraper pools,
round-robinis the default and gives even distribution. For situations where you want the same target site to always egress through the same proxy (some sites set IP-bound cookies), usecarp:cache_peer proxy1.provider.com parent 8080 0 carp no-query login=user1:pass1 cache_peer proxy2.provider.com parent 8080 0 carp no-query login=user2:pass2 # ... etcCARP hashes each URL and consistently picks the same peer for the same URL. This means a session of requests to the same target uses the same proxy, which avoids tripping IP-binding checks.
Failover and health checks
Squid pings parent peers periodically and removes failing ones from the pool. Configure the failure threshold:
# Mark a peer dead after 3 consecutive failures connect_timeout 30 seconds peer_connect_timeout 10 seconds dead_peer_timeout 5 minutesWhen a peer is dead, Squid skips it until the dead_peer_timeout elapses. After that, it tries again. If the peer responds, it is brought back into the pool.
For more aggressive failover, write a custom health check that probes upstreams every minute and rewrites the Squid config. Sketch:
#!/usr/bin/env python3 # squid_peer_healthcheck.py import requests import subprocess import time PEERS = [ ("proxy1.provider.com", 8080, "user1", "pass1"), ("proxy2.provider.com", 8080, "user2", "pass2"), # ... ] def check_peer(host, port, user, password): try: proxy_url = f"http://{user}:{password}@{host}:{port}" r = requests.get( "https://httpbin.org/ip", proxies={"https": proxy_url}, timeout=10, ) return r.status_code == 200 except Exception: return False def write_squid_config(healthy_peers): with open("/etc/squid/peers.conf", "w") as f: for i, (host, port, user, password) in enumerate(healthy_peers, 1): f.write( f"cache_peer {host} parent {port} 0 round-robin no-query " f"login={user}:{password} name=peer_{i}\n" ) def reload_squid(): subprocess.run(["sudo", "systemctl", "reload", "squid"], check=True) def main(): healthy = [p for p in PEERS if check_peer(*p)] print(f"{len(healthy)}/{len(PEERS)} peers healthy") write_squid_config(healthy) reload_squid() if __name__ == "__main__": main()Run via cron every minute. Include
peers.confin your mainsquid.confvia:include /etc/squid/peers.confThis pattern auto-removes failing peers without manual intervention.
ACL hygiene
By default, Squid is open to everything once you grant http_access. For a scraper pool, restrict access to your scraper IPs:
# Define allowed clients acl scraper_clients src 10.0.0.0/8 192.168.0.0/16 172.16.0.0/12 # Allow only those IPs http_access allow scraper_clients http_access deny allFor authenticated client access (when scrapers run on internet IPs), use HTTP Basic auth:
auth_param basic program /usr/lib/squid/basic_ncsa_auth /etc/squid/passwd auth_param basic realm "Scraper Proxy Pool" acl authenticated proxy_auth REQUIRED http_access allow authenticated http_access deny allGenerate the password file:
sudo htpasswd -c /etc/squid/passwd scraper_user sudo chown proxy:proxy /etc/squid/passwd sudo chmod 640 /etc/squid/passwdPer-tenant rate limiting
If you serve multiple internal teams from one Squid pool, rate limit per team:
# Define teams by subnet acl team_a src 10.1.0.0/16 acl team_b src 10.2.0.0/16 # Delay pools (rate limiting) delay_pools 2 # Pool 1: team_a, 10 MB/s aggregate, 1 MB/s per host delay_class 1 2 delay_parameters 1 10000000/10000000 1000000/1000000 delay_access 1 allow team_a delay_access 1 deny all # Pool 2: team_b, 5 MB/s aggregate delay_class 2 1 delay_parameters 2 5000000/5000000 delay_access 2 allow team_b delay_access 2 deny allThis caps total bandwidth per team, useful when one runaway scraper would otherwise saturate your upstream contracts.
Logging
Default Squid log format is fine for debugging. For monitoring, switch to a structured log format:
logformat scraperjson { \ "ts": "%ts.%03tu", \ "client_ip": "%>a", \ "duration_ms": %tr, \ "status": "%>Hs", \ "bytes_sent": %<st, \ "method": "%rm", \ "url": "%ru", \ "peer_used": "%<a", \ "user_agent": "%{User-Agent}>h" \ } access_log /var/log/squid/access.log scraperjsonThen ingest via Vector, Fluent Bit, or a similar log shipper into Loki or Elasticsearch for querying. Common queries:
- Per-peer success rate: count of
status=200group bypeer_used - Per-team bandwidth: sum of
bytes_sentgroup byclient_ipblock - Slow upstreams: p95 of
duration_msgroup bypeer_used
These queries highlight which upstream peers are degrading and which scrapers are hammering the pool.
Monitoring with Prometheus
Squid exposes runtime stats via
squidclient mgr:info. Convert to Prometheus metrics with a small exporter:# squid_exporter.py from prometheus_client import start_http_server, Gauge import subprocess import re import time requests_total = Gauge("squid_requests_total", "Total requests", ["state"]) peer_status = Gauge("squid_peer_status", "Peer up/down", ["peer"]) def parse_squid_info(): out = subprocess.check_output(["squidclient", "mgr:info"]).decode() # Parse lines like "Number of HTTP requests received: 12345" m = re.search(r"Number of HTTP requests received:\s+(\d+)", out) if m: requests_total.labels(state="received").set(int(m.group(1))) def parse_peer_status(): out = subprocess.check_output(["squidclient", "mgr:server_list"]).decode() # Parse peer status lines for match in re.finditer(r"Host\s*:\s*(\S+).*?Status\s*:\s*(\w+)", out, re.DOTALL): peer, status = match.group(1), match.group(2) peer_status.labels(peer=peer).set(1 if status == "ALIVE" else 0) if __name__ == "__main__": start_http_server(9301) while True: parse_squid_info() parse_peer_status() time.sleep(15)Scrape from Prometheus and visualize peer health in Grafana. Alert when any peer flips to dead or aggregate request rate drops below baseline.
Python client integration
Scrapers integrate with the Squid pool by setting it as the HTTPS proxy. Most clients support this via standard environment variables or per-call config.
For curl_cffi:
from curl_cffi import requests resp = requests.get( "https://target.example.com", impersonate="chrome124", proxies={ "http": "http://scraper_user:secret@squid.internal:3128", "https": "http://scraper_user:secret@squid.internal:3128", }, )For Playwright:
from patchright.async_api import async_playwright async def fetch(): async with async_playwright() as p: browser = await p.chromium.launch( proxy={ "server": "http://squid.internal:3128", "username": "scraper_user", "password": "secret", }, ) # ...The single Squid endpoint masks the entire upstream pool from your scraper code. Add or remove upstream peers without changing scraper config.
Comparison: Squid vs alternatives
tool strength weakness Squid mature, ACLs, monitoring HTTP-first, complex config HAProxy very fast, modern config less ACL flexibility for proxy use Nginx (with stream module) fast, widely known weaker for proxy pooling specifically 3proxy SOCKS5 support smaller community, less feature-rich custom Go service exactly what you want maintenance burden commercial gateway (Bright Data, Oxylabs) zero infra expensive at scale For HTTP/HTTPS proxy pooling, Squid is the right pick. For SOCKS5, 3proxy or a custom Go service. For very high throughput, HAProxy or a custom service.
For broader proxy infrastructure patterns, see self-hosted proxy infrastructure: complete 2026 guide.
Operational considerations
A Squid pool in production needs:
- Hardware: 4 vCPU, 8 GB RAM handles 10k req/s for typical scraper traffic
- Bandwidth: budget for outgoing + incoming, rule of thumb 1-2 Mbit/s per active scraper worker
- Disk: 100 GB for logs (rotate daily, keep 30 days)
- Monitoring: peer status, request rate, error rate, p95 latency
- Alerting: any peer dead, error rate >5%, latency p95 >1s
- Backup: config and password file in version control
- Security: ACL restricting access, never expose 3128 to the internet
For scaling, run multiple Squid instances behind a load balancer (HAProxy or a TCP load balancer). Each Squid instance has the same peer pool, so requests distribute evenly across the cluster.
Common failure modes
- All peers marked dead: usually a transient upstream provider issue or DNS failure. Check upstream connectivity directly.
- High latency: an upstream is slow. Check per-peer p95 latency in your logs.
- High error rate: upstream is degraded. Health check script should auto-remove.
- Connection refused: Squid is not running or wrong port. Check systemctl.
- 407 Proxy Auth Required: ACL is blocking. Check
auth_paramandhttp_accessorder. - 403 Forbidden from Squid itself: the target hit a Squid-side ACL. Check
http_accessrules. - Memory growing without bound: a buggy Squid version or misconfigured cache. Check that
cache deny allis set.
For OWASP guidance on proxy security, see the OWASP Proxy security cheat sheet.
Operational checklist
- Squid 6.x on Ubuntu 22.04 or 24.04
- Cache disabled, forwarded_for delete, via off
- Parent peers with login auth and round-robin
- never_direct allow all
- ACL restricting client access
- Health check cron rotating peers in/out
- Structured JSON access log
- Prometheus exporter + Grafana dashboard
- Alerting on peer status and error rate
- Config in version control
- Multiple Squid instances behind LB for scale
FAQ
Q: how many upstream peers do I need?
Match your concurrency. If you run 100 concurrent scraper workers and each makes 1 request/second, you have 100 req/s. With 5 peers in round-robin, each peer handles 20 req/s. Most residential proxy plans handle that comfortably. For high-volume work, 20-50 peers gives you headroom and failover capacity.Q: can Squid rotate proxies on every request to a single domain?
Yes withround-robin. Squid picks the next peer per request regardless of destination. For per-domain affinity (same domain always uses same peer), usecarp.Q: does Squid support SOCKS5 upstreams?
Squid 6.x added SOCKS5 support via thecache_peerconnection-auth=off type=parentoptions, but it is less mature than HTTP support. For SOCKS5-heavy work, 3proxy is more reliable.Q: how do I add or remove peers without restart?
Edit/etc/squid/peers.confand reload Squid:sudo systemctl reload squid. Reload is graceful and does not drop active connections.Q: can I use Squid for HTTPS interception?
Yes with SSL bumping, but it requires installing a CA certificate on every client. For scrapers, this is usually unnecessary because you do not need to inspect HTTPS content, just forward it. Stick to CONNECT method for HTTPS.Common pitfalls in production Squid pools
The first failure mode is silent peer exhaustion under high concurrency. Squid’s
cache_peerwithround-robindoes not automatically queue requests when peers are busy: it picks the next peer in sequence regardless of that peer’s current load. If your scraper fleet sends 500 concurrent requests through 5 peers, each peer receives 100 simultaneous connections. Most residential providers cap concurrent connections at 50-100 per credential set, so half your requests stall or fail with upstream timeouts. The fix is to addmax-conn=Nto eachcache_peerline (Squid 5.0+) so Squid stops sending new requests to peers at their capacity:cache_peer proxy1.provider.com parent 8080 0 round-robin no-query \ login=user1:pass1 name=peer_1 max-conn=50The second pitfall is DNS leakage to upstream proxies. By default Squid resolves hostnames locally before forwarding, which means the upstream proxy receives an IP address rather than a hostname. Many residential proxy providers route by hostname (their internal load balancer chooses an exit IP based on the destination domain), so receiving a pre-resolved IP causes them to skip routing optimizations or return 502s. Add
client_dst_passthru offplushost_verify_strict offand configure Squid to send the original CONNECT host:host_verify_strict off client_dst_passthru off forwarded_for deleteFor HTTP CONNECT (HTTPS tunneling), Squid passes the original host to the upstream by default. Verify by checking the upstream’s logs that they receive
CONNECT example.com:443rather thanCONNECT 93.184.216.34:443.The third pitfall is Squid’s stale connection pool to dead upstreams. When a peer dies and
dead_peer_timeoutexpires, Squid retries it. If the peer is still dead, Squid waits for the connect_timeout (30s default) before failing the request. During that wait, upstream requests through that peer hang, and your scrapers see 30-second latency spikes. Setpeer_connect_timeout 5 secondsto fail fast on dead peers, and pair with the active health check script that runs every 60 seconds to remove dead peers from the config entirely:peer_connect_timeout 5 seconds connect_timeout 15 secondsReal-world example: 95th percentile latency tuning
A scraper farm running 80 concurrent workers through a Squid pool of 12 residential upstreams saw average latency of 800ms but p95 of 14 seconds. Investigation showed two of the 12 upstreams were experiencing intermittent 10-15 second response delays during certain hours, but Squid’s round-robin still routed traffic to them. The fix combined three changes: first, lowering
peer_connect_timeoutfrom 30s to 5s so dead-feeling peers were skipped faster; second, addingmax-conn=40on each peer so a slow peer did not accumulate stuck connections; third, switching fromround-robintoweighted-round-robinand assigning the two slow peersweight=1while the fast peers gotweight=10:cache_peer fast_proxy1.provider.com parent 8080 0 weighted-round-robin no-query \ login=user1:pass1 name=fast_1 weight=10 max-conn=40 cache_peer slow_proxy1.provider.com parent 8080 0 weighted-round-robin no-query \ login=user2:pass2 name=slow_1 weight=1 max-conn=40After deployment, p95 latency dropped from 14s to 2.1s within an hour and p99 from 22s to 4.3s. The slow peers still received 1/10th of traffic (useful for monitoring whether they recovered) without dragging down the overall pool experience. The lesson: Squid’s defaults assume homogeneous peers, but real-world residential pools are heterogeneous and need weight-based shaping plus aggressive timeouts to keep the tail latency bounded.
Wrapping up
A Squid-based rotating proxy pool gives you per-tenant control, automatic failover, and simple integration with any scraping client for the cost of a small VM and 50 lines of config. It pays for itself within weeks compared to commercial gateway pricing if you have moderate volume. Pair this with our self-hosted proxy infrastructure and best residential proxy providers 2026 guides for the full stack, and browse the dev-tools-projects category on DRT for related infrastructure deep-dives.
-
Best Go scraping libraries 2026 ranked
Best Go scraping libraries 2026 ranked
Best Go scraping libraries in 2026 occupy a niche that is small but unusually high-leverage. Go’s concurrency model (goroutines and channels) maps almost perfectly to the scraping problem, and Go’s compiled binary makes deployment dramatically simpler than Python or Node alternatives. The downside is library breadth: the Go scraping ecosystem has fewer options than Python’s, and the existing libraries are less actively maintained on average. For specific workloads (high-throughput HTTP scraping, distributed crawler workers, scraping infrastructure embedded in Go services), Go is the right choice and the libraries that exist are excellent. For one-off scrapers or projects that benefit from a rich ecosystem, Python or Node remain easier.
This guide ranks the Go scraping libraries actually worth using in 2026, with honest performance comparisons, clear use case mapping, and the gotchas specific to Go’s approach.
Why Go for scraping
Three reasons Go is interesting for scrapers:
Concurrency: a goroutine costs about 2 KB of stack memory. You can run 10,000+ concurrent goroutines on a modest server. Compared to Python’s coroutine overhead and Node’s event loop limits, Go’s concurrency is genuinely different in scale.
Compile-once deploy-anywhere: a Go binary is a single static file. Deployment to a new server or container is
scpand./scraper. No virtualenv, no node_modules, no version drift between dev and prod.HTTP performance: Go’s
net/httpstandard library is fast enough that “scraping” and “high-performance HTTP service” use the same toolkit. fasthttp pushes performance even further for extreme throughput needs.Three reasons Go is sometimes wrong:
Smaller library ecosystem: fewer parsers, fewer pre-built scrapers, less community content.
No native browser automation: Chromedp and Rod are good but not as polished as Playwright in Python or JavaScript.
Verbose for one-offs: Python’s
requeststwo-line scraper has no clean Go equivalent.HTTP clients
net/http (standard library)
The standard library client. Production-grade, well-documented, fast. Right choice for most scraping HTTP needs.
package main import ( "io" "net/http" "time" ) func fetch(url string) ([]byte, error) { client := &http.Client{Timeout: 10 * time.Second} req, err := http.NewRequest("GET", url, nil) if err != nil { return nil, err } req.Header.Set("User-Agent", "Mozilla/5.0 ...") resp, err := client.Do(req) if err != nil { return nil, err } defer resp.Body.Close() return io.ReadAll(resp.Body) }Best for: most Go HTTP work. Default unless you have specific needs.
fasthttp
Aggressive performance-oriented HTTP library that bypasses some net/http abstractions for raw speed. 5-10x faster than net/http on benchmarks. The API is different (uses
fasthttp.Requestandfasthttp.Responseinstead of net/http types).import "github.com/valyala/fasthttp" func fetchFast(url string) ([]byte, error) { req := fasthttp.AcquireRequest() resp := fasthttp.AcquireResponse() defer fasthttp.ReleaseRequest(req) defer fasthttp.ReleaseResponse(resp) req.SetRequestURI(url) req.Header.SetUserAgent("Mozilla/5.0 ...") if err := fasthttp.Do(req, resp); err != nil { return nil, err } return resp.Body(), nil }Best for: extreme throughput needs (10k+ requests/sec), low-latency requirements.
resty
The popular convenience HTTP client wrapping net/http with a nicer API. Fluent builder pattern, JSON serialization, retry support. Slightly slower than raw net/http but more readable.
import "github.com/go-resty/resty/v2" client := resty.New().SetTimeout(10 * time.Second) resp, err := client.R(). SetHeader("User-Agent", "Mozilla/5.0"). Get("https://example.com")Best for: developer ergonomics, projects that benefit from convenience over absolute performance.
HTML parsers
GoQuery
The jQuery-style HTML parser. Cleanest API for Go HTML manipulation. Built on
golang.org/x/net/htmlunder the hood.import ( "github.com/PuerkitoBio/goquery" "strings" ) func parseTitles(html string) []string { doc, err := goquery.NewDocumentFromReader(strings.NewReader(html)) if err != nil { return nil } var titles []string doc.Find("h2.product-title").Each(func(i int, s *goquery.Selection) { titles = append(titles, s.Text()) }) return titles }Best for: most Go HTML parsing. Default choice.
golang.org/x/net/html
The standard parser GoQuery wraps. Direct use is verbose but available for custom AST manipulation.
Best for: low-level parsing needs, when you want zero dependencies.
colly’s parser
Colly framework includes its own HTML traversal which is less verbose than GoQuery for callback-driven scraping. Used in conjunction with Colly only.
Browser automation
Chromedp
The dominant Go browser automation library. Uses Chrome DevTools Protocol directly without intermediate libraries. Fast, well-maintained, but the API is verbose compared to Playwright.
import ( "context" "github.com/chromedp/chromedp" "time" ) func scrapeWithChrome(url string) (string, error) { ctx, cancel := chromedp.NewContext(context.Background()) defer cancel() ctx, cancel = context.WithTimeout(ctx, 30*time.Second) defer cancel() var title string err := chromedp.Run(ctx, chromedp.Navigate(url), chromedp.WaitVisible("h1.product-title"), chromedp.Text("h1.product-title", &title), ) return title, err }Best for: most Go browser automation. The default choice when you need a real browser.
Rod
A modern alternative to Chromedp with a more fluent API. Active development, strong feature parity with Playwright.
import "github.com/go-rod/rod" browser := rod.New().MustConnect() page := browser.MustPage("https://example.com").MustWaitLoad() title := page.MustElement("h1.product-title").MustText()Best for: developers who prefer Rod’s API ergonomics over Chromedp’s.
Playwright-go
The Microsoft Playwright API for Go. Newer and less mature than Chromedp/Rod but offers cross-browser (Firefox, WebKit) support that the Chrome-only alternatives lack.
Best for: cross-browser needs in Go, teams using Playwright in other languages.
Frameworks
Colly
The dominant Go scraping framework. Built-in caching, concurrency, request rate limiting, and HTML parsing callbacks. The right choice for crawler-heavy Go scrapers.
import "github.com/gocolly/colly/v2" c := colly.NewCollector( colly.AllowedDomains("example.com"), colly.Async(true), ) c.Limit(&colly.LimitRule{ DomainGlob: "*", Parallelism: 10, Delay: 100 * time.Millisecond, }) c.OnHTML("div.product", func(e *colly.HTMLElement) { fmt.Println(e.ChildText("h2.title")) }) c.OnHTML("a.next", func(e *colly.HTMLElement) { e.Request.Visit(e.Attr("href")) }) c.Visit("https://shop.example.com/page/1") c.Wait()Best for: large crawlers, the standard Go scraping framework.
Geziyor
Another Go scraping framework with similar feature set to Colly. Less popular but actively maintained.
Best for: Colly alternatives.
Comparison table
library layer speed learning curve best for net/http HTTP fast easy most HTTP work fasthttp HTTP fastest medium extreme throughput resty HTTP fast easy developer ergonomics GoQuery parser fast easy most HTML parsing golang.org/x/net/html parser fast hard custom AST work Chromedp browser mid medium most browser automation Rod browser mid medium Chromedp alternative Playwright-go browser mid medium cross-browser Colly framework fast medium most crawler work Geziyor framework fast medium Colly alternative Decision matrix: solopreneur, SMB, enterprise
profile scale recommended stack reasoning Solopreneur Go-curious <10k pages/day net/http + GoQuery Standard library + the one parser Indie scraper, single binary <500k pages/day net/http + GoQuery + Colly Framework value at this scale Indie extreme throughput <1M pages/day fasthttp + GoQuery When net/http becomes a bottleneck SMB scraping infra 1-10M pages/day Colly + Redis queue + custom workers Distribute across N binaries SMB JS-heavy <500k pages/day Chromedp + GoQuery post-parse Browser only when needed Embedded scraping in service varies net/http only Avoid framework imports inside larger services Enterprise data pipeline 10M+ pages/day Custom Go workers + Kafka + GoQuery Maximum control, minimum dependencies The right pattern for Go at scale is custom workers reading from a queue rather than a monolithic Colly process. Goroutines do the concurrency; Redis or NATS does the work distribution. This pattern scales linearly with worker count and survives single-machine failures cleanly.
Migration path: Python or Node to Go
Most Go migrations happen when Python or Node scrapers hit infrastructure limits at scale. The playbook:
- Identify the throughput bottleneck. If your Python scraper saturates one CPU core at 800 req/s, Go can run the same workload at 4000+ req/s on one core. If you are not CPU-bound, the migration may not pay off.
- Port one scraper end-to-end. Choose the highest-throughput single-target scraper as the migration pilot. Validate output equivalence on a sample.
- Keep Python or Node for orchestration. Many teams use Go for the scraper workers and Python for the data pipeline (Pandas, ML preprocessing). The tools do not have to match.
- Containerize and deploy in parallel. Run Go workers alongside Python workers reading from the same queue. Cut over by reducing Python worker count over a few weeks.
- Re-evaluate at six months. If the Go workers are stable and the throughput gain is real, migrate the rest. If they are not, the original choice was right.
The migration is rarely binary. Most production scrapers end up polyglot with Go for hot-path workers and Python for one-off and analytical work.
Performance benchmarks
Same workload as Python and Node benchmarks: 10,000 simple HTML pages from a local mirror, single Go binary.
stack total time requests/sec net/http (50 goroutines) 6s 1666 fasthttp (50 goroutines) 3s 3333 resty (50 goroutines) 8s 1250 Colly (default) 7s 1428 Chromedp (50 contexts) 110s 90 Go HTTP throughput is the highest of the three languages we benchmarked. fasthttp specifically is faster than even Node’s undici for this workload. Browser automation is similar across all languages because the bottleneck is browser execution.
Cost worked example
For a 1M-pages-per-day Go scraping workload (roughly 12 req/s sustained):
- 1 medium VPS ($40/mo, 8 vCPU, 16 GB)
- net/http + GoQuery + Colly stack (free)
- uTLS for TLS fingerprint impersonation when needed (free)
- Smartproxy/Decodo residential proxies (~$200/mo for ~25 GB)
- Redis on a small managed instance ($10/mo) for distributed work coordination
- PostgreSQL on a hosted instance ($25/mo)
Total: about $275/month for a workload that handles 30 million pages per month. The Python or Node equivalent would need 2-3x the compute capacity for the same throughput, raising infrastructure cost by $80-120/month. Go’s compiled binary also reduces deployment complexity (no language runtime, no virtualenv) and operational toil.
The break-even point where Go’s lower compute cost overcomes its higher development cost typically sits around 10M pages/month. Below that, Python or Node ergonomics usually win on total team productivity.
Stack recommendations
Most Go scraping: net/http + GoQuery + Colly. Standard library plus the two best community libraries. Adequate for almost everything.
Extreme throughput: fasthttp + GoQuery + Colly. When you need 10k+ HTTP requests per second per machine.
Browser-required scraping: Chromedp + GoQuery (for parsing extracted HTML). Use Chromedp for JS execution, GoQuery for the parsing because it is more ergonomic.
Distributed scraping: net/http + GoQuery + custom code with Redis queues. Colly does not have great distributed support; for multi-machine scrapers you build the coordination layer yourself.
Scraping inside a larger Go service: net/http directly. Avoid pulling in framework overhead for embedded scraping inside a service that does other things.
Idiomatic Go scraper template
A modern Go scraper using standard libraries:
package main import ( "context" "fmt" "io" "net/http" "strings" "sync" "time" "github.com/PuerkitoBio/goquery" ) type Product struct { Name string Price string URL string } func fetchPage(ctx context.Context, url string) (string, error) { req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { return "", err } req.Header.Set("User-Agent", "Mozilla/5.0") client := &http.Client{Timeout: 15 * time.Second} resp, err := client.Do(req) if err != nil { return "", err } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) return string(body), err } func parseProducts(html string) []Product { doc, err := goquery.NewDocumentFromReader(strings.NewReader(html)) if err != nil { return nil } var products []Product doc.Find("div.product-card").Each(func(i int, s *goquery.Selection) { products = append(products, Product{ Name: s.Find("h2.title").Text(), Price: s.Find("span.price").Text(), URL: s.Find("a").AttrOr("href", ""), }) }) return products } func scrapeAll(urls []string, concurrency int) []Product { sem := make(chan struct{}, concurrency) var wg sync.WaitGroup var mu sync.Mutex var allProducts []Product for _, url := range urls { wg.Add(1) go func(u string) { defer wg.Done() sem <- struct{}{} defer func() { <-sem }() html, err := fetchPage(context.Background(), u) if err != nil { fmt.Println("error:", err) return } products := parseProducts(html) mu.Lock() allProducts = append(allProducts, products...) mu.Unlock() }(url) } wg.Wait() return allProducts } func main() { urls := []string{"https://example.com/p/1", "https://example.com/p/2"} products := scrapeAll(urls, 20) for _, p := range products { fmt.Printf("%+v\n", p) } }This pattern handles 1500+ pages per minute on a small VPS with proper concurrency control.
Distributed scraper architecture
For workloads that exceed one machine, the canonical Go scraper architecture is:
- Coordinator service that pushes URLs to a queue (Redis Streams, NATS JetStream, or Kafka).
- Worker pool of N stateless Go binaries, each consuming from the queue, scraping in parallel goroutines, and writing results to a sink (Postgres, S3, Kafka).
- Health and metrics exposed via Prometheus endpoints on each worker; scraped via a central Prometheus + Grafana stack.
- Dead-letter queue for URLs that fail repeatedly, picked up by a slower retry process or surfaced for manual investigation.
This pattern scales linearly: doubling worker count doubles throughput up until the target rate-limits or the queue itself bottlenecks. With NATS or Kafka, the queue layer easily handles 100k messages/sec, far beyond what most scrapers need.
Common mistakes to avoid
Forgetting to close response bodies: every HTTP response body must be closed or you leak file descriptors. The
defer resp.Body.Close()pattern is essential.Unbounded goroutine spawning: launching one goroutine per URL without a semaphore exhausts memory and overwhelms target sites. Use a buffered channel as a semaphore.
Using fasthttp when you do not need it: fasthttp’s API is different from net/http and the integration cost is real. For most workloads, net/http is fast enough.
Ignoring context cancellation: pass
context.Contextthrough your scraper functions so you can cancel cleanly on shutdown signals.Trying to use Python-style async patterns: Go’s concurrency primitives (goroutines, channels, sync.WaitGroup) are different from async/await. Embrace them rather than fighting them.
We cover the Python and Node alternatives in our best Python scraping libraries 2026 and best Node.js scraping libraries 2026 reviews.
External authoritative reference: the Go net/http documentation covers the standard library client.
Common gotchas
- Goroutine leaks. Goroutines started without a clear exit path can leak forever if their channel never closes. Always have a
context.Done()check or aselectwith a timeout case. - net/http connection reuse defaults. The default Transport reuses connections, which is good for performance but bad if you want each request from a fresh proxy. For per-request isolation, set
Transport.DisableKeepAlives = true. - fasthttp’s API allocations.
fasthttp.RequestandResponseare pooled; you mustAcquireandReleasethem. ForgettingReleasecauses memory growth that looks like a leak. - GoQuery selector syntax differences. GoQuery uses CSS selectors but does not support all jQuery extensions.
:contains()is supported,:has()is not. Test your selectors against the actual DOM before assuming. - Chromedp context cancellation. Cancelling the parent context kills all in-flight Chrome operations, but the headless Chrome process can survive. Always call
chromedp.Cancel(ctx)explicitly to ensure cleanup. - JSON unmarshaling silent failures. Unknown fields are silently dropped by
json.Unmarshal. If your target’s response shape changes, you may not notice. UseDisallowUnknownFields()on the decoder during development. - Slice append concurrency. Multiple goroutines appending to the same slice corrupt it. Use a
sync.Mutexor a channel-based aggregator. - Colly OnHTML callback ordering. Multiple
OnHTMLhandlers for overlapping selectors fire in registration order, not in DOM order. Test handler ordering if you depend on it.
When to use Go vs Python vs Node
consideration best language highest HTTP throughput per machine Go richest ecosystem Python best browser automation Python or Node (Playwright) simplest deployment Go (single binary) smallest learning curve Python best for embedded scraping in services Go largest community of scraping content Python AI/LLM integration Python For dedicated scraping projects that scale and benefit from compiled performance, Go is excellent. For one-offs and projects requiring rich ecosystem support, Python wins. For Node-shop infrastructure, Node Crawlee fits naturally.
FAQ
Q: Colly or write my own?
For projects with link-following, deduplication, and rate-limiting needs across thousands of pages, Colly saves significant code. For simple scrapers with a known URL list, raw net/http + GoQuery is enough.Q: Chromedp or Rod or Playwright-go?
Chromedp is the safe default with the most production usage. Rod has a nicer API. Playwright-go is right when you need Firefox or WebKit. Performance is similar across all three.Q: how do I handle TLS fingerprinting in Go?
Go’s TLS stack does not have first-class fingerprint impersonation. The closest options areutls(uTLS) which mimics specific browser TLS handshakes, and routing through a proxy that handles fingerprinting.Q: is fasthttp worth the complexity?
For most workloads, no. fasthttp gives you an extra 2-5x throughput at the cost of API divergence from the standard library. Use it when you have measured a performance need that net/http cannot meet.Q: does Go have an equivalent to Scrapy?
Colly is the closest. Less batteries-included than Scrapy but covers the core crawler patterns.Q: how do I handle proxies in Go?
SetTransport.Proxyon yourhttp.Client. For per-request proxy rotation, build a custom Transport that selects a proxy from a pool. Colly accepts a proxy switcher function natively.Q: are there structured-data extraction libraries?
A few exist (go-rod/rodfor browser,tdewolff/parsefor streaming HTML). For strongly typed extraction, write a struct and unmarshal CSS selectors into it manually with reflection or code generation.Q: is Go’s gc a problem for long-running scrapers?
Generally no. Go’s GC has been excellent since 1.14 with sub-millisecond pauses. The main GC concern is allocating in tight loops; reuse buffers and pool objects withsync.Poolif you see GC pressure.Closing
Go scraping in 2026 is the right choice for high-throughput dedicated scraping infrastructure, distributed crawler workers, and embedded scraping inside Go services. The ecosystem is smaller than Python’s but the libraries that exist are excellent. net/http + GoQuery + Colly is the standard stack; fasthttp and Chromedp cover specialized needs. For broader scraping infrastructure see our dev-tools-projects category hub.