Your cart is currently empty!
Author: Xavier Fok
-
PacketStream Review 2026: Honest Pricing, Speed Tests, and Whether It’s Still Worth Buying
PacketStream has been around since 2019. That’s old in the residential-proxy world. The pitch was always the same: cheap residential IPs from a peer-to-peer network, billed per GB, no monthly minimum. In 2026 the market has consolidated around Bright Data, Oxylabs, Smartproxy, and a handful of mid-tier players. PacketStream sits in the budget tier. We kept getting the same question from readers: does it still belong in a serious scraping or multi-account stack, or is the price too good to be true?
We bought 50 GB and ran it through the same test harness we use on every provider: SERP scraping, e-commerce price monitoring, account creation against three social platforms, and a 7-day soak test for IP rotation and ban rates. This is what we found, what 2026 pricing actually looks like, and the use cases where PacketStream still earns a spot in our toolkit.
PacketStream 2026 pricing (real numbers)
PacketStream lists one product on the front page: residential proxy bandwidth at $1.00/GB, no commitment. There’s no monthly minimum. No separate datacenter or mobile plan. The dashboard shows:
Pack Price Per GB Pay as you go $1.00/GB $1.00 50 GB $50.00 $1.00 250 GB $237.50 $0.95 1,000 GB $900.00 $0.90 The volume discount caps at 10% off, so the “$1/GB” headline is what most buyers actually pay. For comparison: Bright Data’s cheapest residential is $7.50/GB, Smartproxy is around $7/GB. PacketStream is roughly one-seventh the price of the premium pool providers.
No charge for failed requests. No charge for session control. Sticky sessions hold for 1 to 30 minutes depending on what you set.
How the network actually works
PacketStream is peer-to-peer. Real users install the PacketStream app on their home computers and earn money for the bandwidth they share. Your proxy request gets routed through one of those volunteer machines. Same model as SOAX, Bright Data residential, IPRoyal. The difference is pool size. PacketStream claims around 7 million IPs in 2026. Bright Data claims 72 million. The PacketStream pool also skews heavily North America and Europe.
What this means in practice:
- IP quality is uneven. Some peer IPs are clean residential connections. Some are from devices running other apps that ping suspicious endpoints. Some are on subnets streaming sites have already flagged. We hit a CAPTCHA on Google about 1 in 6 requests at default rotation. Bright Data was about 1 in 40 on the same test.
- Latency is higher. Peer routing adds 80-200 ms versus a datacenter exit. Median was 320 ms to a US target through US peers, P95 was 880 ms.
- Per-peer bandwidth ceilings apply. A single peer can’t sustain heavy throughput. PacketStream rotates you off a slow peer automatically, but for streaming or large file pulls you’ll feel it.
What we tested
50 GB, 9 days, four workloads:
- Google SERP scraping — 10,000 queries, English US results, default rotation
- Walmart and Target price scraping — 5,000 product page hits with cookies on
- Account creation on three platforms — 50 attempts each on Reddit, X, and Pinterest with sticky 5-minute sessions
- Soak test — 50 concurrent threads holding sticky 30-minute sessions for 7 days
Google SERP
- 10,000 requests sent
- 8,400 returned full SERP HTML
- 1,360 returned a CAPTCHA page
- 240 connection errors or timeouts
- 4.2 GB used
84% success rate. Below the 95%+ you get from Bright Data or Oxylabs, but above the 70% we saw from Soax’s cheapest tier. For Google specifically, plan to throw away 1 in 6 unless you slow down or rotate headers.
Walmart and Target
- 5,000 product page requests
- 4,820 returned 200 OK with full HTML
- 180 hit a Cloudflare interstitial or a 429
- 8.7 GB used
96.4% on retail. Walmart in particular passed PacketStream IPs more often than expected. The peer pool probably overlaps with real Walmart customers’ home IPs, which helps.
Account creation
- Reddit: 38/50 accounts created and still alive after 24 hours. 7 hit phone verification. 5 got shadowbanned in the first hour.
- X: 22/50. X’s anti-bot system tightened a lot in 2026 and PacketStream IPs trip the new device-and-IP fingerprint check.
- Pinterest: 45/50. Lower threshold, so this one was easy.
Honestly, we wouldn’t recommend PacketStream as the only proxy layer for social account creation. Pair it with a real anti-detect browser, or use mobile proxies for anything that needs to stick.
Soak test
- 50 concurrent sticky sessions, 30-minute hold each
- Median session uptime: 14 minutes (peer dropped or got rotated)
- Sessions surviving the full 30 minutes: 28%
- Sessions surviving 5+ minutes: 91%
Classic peer-to-peer tradeoff. Short sessions are reliable. Long sessions are not. If you need a stable IP for an hour, this isn’t the tool.
Sticky sessions and rotation control
Two rotation modes through the username string:
- Per-request rotation:
proxy.packetstream.io:31112with usernameyour-user_country-us - Sticky session: add
_session-abc123_lifetime-30to the username
Sticky session IDs are arbitrary strings you generate. Lifetime values are 1, 5, 10, 15, 20, 25, or 30 minutes. Country targeting accepts ISO country codes. City-level targeting is not supported in 2026. Bright Data and Oxylabs still offer it; PacketStream doesn’t.
The geo menu covers 65 countries. Most have under 50k peer IPs, which is fine for general scraping but limiting for geo-locked tests in smaller markets. For the US, UK, Germany, France, and India the pool is big enough that you won’t see the same IP twice across a 50,000-request workload.
Where PacketStream is the right choice
After 9 days of testing, we use PacketStream for:
- Bulk e-commerce scraping where you need millions of requests at a price that won’t blow the budget. 1 TB at $900 is genuinely competitive.
- SERP scraping at scale where 84% success is fine and you can retry the failures cheaply.
- Geo-checking for the 20-30 largest countries when you need a quick country-level validation without paying $7.50/GB.
Where it’s the wrong choice
We wouldn’t use it for:
- Account creation on platforms with 2026-grade anti-bot. X, Instagram, TikTok, Discord all trip on peer IPs that look slightly off. Mobile proxies are the answer here.
- Long sticky sessions over 15 minutes. The peer pool can’t reliably sustain them.
- City-level geo targeting. Not supported.
- Streaming or large file downloads. Per-peer ceilings hurt.
- Banking, ticketing, anything that needs an IP reputation score above 9/10. Some peer IPs have flags from other apps.
PacketStream vs the alternatives in 2026
Provider Price/GB Pool Sticky max Geo control Our use PacketStream $0.90-$1.00 ~7M 30 min Country Bulk scraping Bright Data $7.50-$12.75 72M Unlimited Country, state, city, ASN High-stakes Oxylabs $7.00-$12.00 102M Unlimited Country, city Enterprise Smartproxy (now Decodo) $7.00 65M 30 min Country, city Mid-market IPRoyal $1.75-$2.75 32M Unlimited Country, state, city Budget+ SOAX $4.00-$8.00 8.5M 24 hours Country, region, city, ISP Mobile/residential mix The clean competitor at PacketStream’s price point is IPRoyal at $1.75/GB. IPRoyal gives you a bigger pool, unlimited sticky sessions, and state-level targeting for an extra $0.75/GB. If you only need country targeting and you’re price-sensitive, PacketStream still wins on absolute cost. If you need session stability past 15 minutes, IPRoyal is the better buy.
Sign-up and dashboard
The PacketStream dashboard is one of the cleanest in this category. Bandwidth balance, proxy endpoint, username generator, usage graph. No billing portal buried three menus deep. No support ticket maze. No upsell modals.
Payment is credit card or crypto (BTC, ETH, USDT). Crypto credits within 20 minutes in our test. There’s no free trial, but you can start with $5 and validate on your workload before scaling.
API access for usage stats and balance is documented at
packetstream.io/api. You get per-day bandwidth consumption and a per-country breakdown, which is enough for cost allocation.Customer support
Email only. No phone. No live chat. We sent three tickets and got replies in 4 hours, 11 hours, and 2 days. Quality was decent on technical questions, generic on account stuff. This is below Bright Data’s 24/7 live chat and roughly equal to IPRoyal’s email-only model.
For agencies running production scrapers, the lack of priority support is a real concern. If you need someone on the phone when your scraper breaks at 2 AM, look elsewhere.
Compliance and TOS
The TOS prohibits using the proxies for spam, account creation on banned-list platforms, and anything targeting individuals. The banned-platform list is short and changes from time to time. Most legitimate use cases (SERP, e-commerce, public data) are explicitly allowed.
The compliance posture is lighter than Bright Data’s, which requires KYC for high-risk targets. PacketStream’s peer-to-peer model also raises a question about consent. Peers sign up to share bandwidth, but the legal framing is “the peer is making the request” not “PacketStream is the proxy.” Whether that distinction matters for your own legal exposure depends on jurisdiction. Worth checking with a lawyer if you’re scraping anything sensetive.
Verdict
PacketStream in 2026 is a credible budget residential proxy for bulk e-commerce scraping, SERP work, and country-level geo testing. At $1/GB it has no real peer for pure price. The tradeoffs are real though: 84% success on Google, short reliable sticky sessions, no city targeting, peer-quality variance. If your workload tolerates retries and short sessions, PacketStream pays for itself many times over compared to the premium pool providers.
If you need long sticky sessions, city-level targeting, top-tier IP reputation, or premium support, look at IPRoyal first (best price-to-features ratio in 2026) and Bright Data or Oxylabs for enterprise.
For our own work, we keep a PacketStream account funded specifically for the price scraping pipeline, and a separate mobile proxy account for everything that needs to actually stick.
Frequently asked questions
Is PacketStream legit in 2026?
Yes. Operational since 2019, payments processed through a US entity, documented peer onboarding flow. The peer-to-peer model is the same as IPRoyal, SOAX, and Bright Data residential. Quality varies but the service is real.
How does it compare to Bright Data?
PacketStream is one-seventh the price per GB. Bright Data has 10x the pool, better IP reputation, unlimited sticky sessions, city and ASN targeting, and 24/7 live chat. For high-stakes work, Bright Data is worth the premium. For bulk and budget work, PacketStream wins.
Can I use it for sneaker bots, ticket scalping, or account creation?
Not really. The peer pool isn’t optimized for these. Sneaker sites and ticketing sites flag the same IPs that look fine for SERP scraping. Hard-target account creation (X, Instagram, TikTok) will struggle. Use mobile proxies for these workloads.
Does PacketStream offer mobile or datacenter proxies?
No. Residential only in 2026.
Is there a free trial?
No, but $5 gets you about 5 GB of testing. Enough to validate your workload before scaling.
Can I set a budget cap?
Yes. The dashboard has a daily spending cap. When you hit it the proxy stops responding until the next billing day or you raise the cap.
How fast is it?
Median 320 ms to a US target through US peers, P95 of 880 ms. Faster than Bright Data residential in some cases (fewer peer hops), slower than datacenter proxies by 200-400 ms.
What happens if a peer disconnects mid-session?
PacketStream rotates you to another peer automatically. For per-request rotation, invisible. For sticky sessions, it looks like a session reset, which breaks workflows that need a stable IP for longer than the peer’s actual uptime. In our 30-minute soak test, only 28% of sessions held the full 30 minutes.
SOCKS5 support?
Yes, on a separate port. HTTP proxy is on 31112, SOCKS5 is on a different port shown in the dashboard. Both use the same authentication string.
Should I buy it?
Buy if you need cheap residential bandwidth for bulk scraping and you can tolerate retries. Skip if you need premium IP reputation, long sticky sessions, city-level targeting, or production-grade support.
-
Building scraping pipelines with Dagster in 2026
Building scraping pipelines with Dagster in 2026
Building scraping pipelines with Dagster is the right answer when scraping is one part of a larger data engineering pipeline. Dagster reframes orchestration around data assets rather than tasks: instead of writing “fetch URLs, parse, store” as separate steps, you declare “products dataset” as a software-defined asset that depends on “raw HTML pages” which depends on “URL list,” and Dagster figures out the execution graph. For pure scraping with no downstream analytics, this is overkill. For scraping that feeds into ML models, dashboards, or warehouses, Dagster’s asset-aware approach pays off in lineage, partitioning, and freshness tracking.
This guide covers Dagster 1.7+ in 2026 for scraping pipelines: software-defined assets, jobs, sensors, partitioning by time and host, IO managers, and a complete end-to-end example. Code is Python 3.12. By the end you will know whether Dagster fits your scraping use case and how to deploy a working pipeline.
Why Dagster for scraping
Dagster’s strengths for scraper-heavy pipelines:
- Software-defined assets: declare data products instead of orchestration steps
- Lineage tracking: see how data flows from raw HTML to final dashboard
- Partition support: per-day, per-host, per-customer partitions with backfill
- IO managers: separate “what to compute” from “where to store it”
- Sensors: trigger pipelines based on external events (file arrival, API webhook)
- Strong type system: Pydantic-style configs and asset metadata
- Asset checks: data quality assertions per asset
- Mature: 6+ years in production, used by Robinhood, Duolingo, etc.
For Dagster’s official documentation, see docs.dagster.io.
Where Dagster does not lead
- For simple scheduled scraping with no downstream pipeline, Prefect or cron are simpler
- For very high-frequency tasks (1000s/sec), the asset model adds overhead
- For teams without data engineering experience, the asset abstraction takes adjustment
For Prefect comparison, see building scraping pipelines with Prefect 3.
Installing Dagster
pip install dagster dagster-webserver dagster --version # 1.7+ # Initialize a project dagster project scaffold --name my-scraper cd my-scraperStart the dev server:
dagster dev # UI at http://localhost:3000A first asset: scraping a URL
# my_scraper/assets.py from dagster import asset, AssetExecutionContext import httpx @asset def homepage_html(context: AssetExecutionContext) -> str: resp = httpx.get("https://example.com", headers={ "User-Agent": "Mozilla/5.0 ...", }) resp.raise_for_status() context.log.info(f"Fetched {len(resp.text)} bytes") return resp.text @asset def homepage_titles(homepage_html: str) -> list[str]: from selectolax.parser import HTMLParser tree = HTMLParser(homepage_html) return [n.text(strip=True) for n in tree.css("h2")]Two assets:
homepage_html(the raw HTML) andhomepage_titles(parsed titles, depends onhomepage_html). Dagster figures out the dependency from the function signature.In the Dagster UI, you see a graph with two nodes connected by a line. Click “Materialize all” and Dagster fetches the page and parses it.
Partitioned assets: scrape per day, per host
For real scraping, you usually want partitions: one materialization per day, per host, or per customer. Dagster partitions track which slices have been materialized and which need backfilling.
from dagster import asset, DailyPartitionsDefinition, AssetExecutionContext from datetime import datetime import httpx daily_partitions = DailyPartitionsDefinition(start_date="2026-01-01") @asset(partitions_def=daily_partitions) def daily_news_html(context: AssetExecutionContext) -> str: date_str = context.partition_key # YYYY-MM-DD url = f"https://news.example.com/archive/{date_str}" resp = httpx.get(url) resp.raise_for_status() return resp.textNow
daily_news_htmlhas one materialization per day. Run today’s: Dagster fetches today’s URL. Backfill last 30 days: Dagster fetches each day’s URL.For multi-dimensional partitions (per-day AND per-host):
from dagster import ( asset, MultiPartitionsDefinition, StaticPartitionsDefinition, DailyPartitionsDefinition, ) hosts = StaticPartitionsDefinition(["example.com", "news.example.com", "shop.example.com"]) date_partitions = DailyPartitionsDefinition(start_date="2026-01-01") multi_partitions = MultiPartitionsDefinition({"date": date_partitions, "host": hosts}) @asset(partitions_def=multi_partitions) def host_daily_pages(context: AssetExecutionContext) -> str: keys = context.partition_key.keys_by_dimension date_str = keys["date"] host = keys["host"] url = f"https://{host}/archive/{date_str}" resp = httpx.get(url) return resp.textThis creates a 2D grid of partitions. Backfill specific cells (date X host) without re-running everything.
IO managers: separating compute from storage
In simple cases, the asset function returns a Python value and Dagster pickles it to local disk. For production, use IO managers to write to Postgres, S3, or your warehouse.
from dagster import IOManager, io_manager, OutputContext, InputContext import boto3 import json import io class S3IOManager(IOManager): def __init__(self, bucket: str): self.bucket = bucket self.s3 = boto3.client("s3") def _key(self, context): # asset_key is a list of strings path = "/".join(context.asset_key.path) if context.has_partition_key: path = f"{path}/{context.partition_key}" return f"{path}.json" def handle_output(self, context: OutputContext, obj): key = self._key(context) body = json.dumps(obj).encode() if not isinstance(obj, bytes) else obj self.s3.put_object(Bucket=self.bucket, Key=key, Body=body) context.log.info(f"Wrote to s3://{self.bucket}/{key}") def load_input(self, context: InputContext): key = self._key(context) resp = self.s3.get_object(Bucket=self.bucket, Key=key) return json.loads(resp["Body"].read()) @io_manager(config_schema={"bucket": str}) def s3_io_manager(init_context): return S3IOManager(bucket=init_context.resource_config["bucket"])Wire it in:
from dagster import Definitions defs = Definitions( assets=[homepage_html, homepage_titles], resources={"io_manager": s3_io_manager.configured({"bucket": "my-scraper-data"})}, )Now every asset’s output goes to S3 instead of local disk. Same code, different storage.
Browser scraping with Playwright as an asset
from dagster import asset, AssetExecutionContext from playwright.sync_api import sync_playwright @asset(partitions_def=daily_partitions) def dynamic_page_html(context: AssetExecutionContext) -> str: date_str = context.partition_key url = f"https://example.com/dynamic?date={date_str}" with sync_playwright() as p: browser = p.chromium.launch(headless=True) page = browser.new_page() page.goto(url, wait_until="networkidle") html = page.content() browser.close() return htmlSame pattern: Playwright runs inside the asset function. Dagster orchestrates.
Jobs: grouping assets
Jobs run sets of assets together. Define a job that materializes a related group:
from dagster import define_asset_job, AssetSelection scrape_news_job = define_asset_job( "scrape_news", selection=AssetSelection.assets("daily_news_html", "daily_news_articles"), )Schedule it:
from dagster import ScheduleDefinition daily_news_schedule = ScheduleDefinition( job=scrape_news_job, cron_schedule="0 6 * * *", # 6am daily )Sensors: event-driven materialization
Sensors trigger asset materialization based on external events:
from dagster import sensor, RunRequest, SkipReason from datetime import datetime import httpx @sensor(job=scrape_news_job) def new_url_sensor(context): # Check an API for new URLs resp = httpx.get("https://api.example.com/new-urls") new_urls = resp.json() if not new_urls: return SkipReason("No new URLs") return RunRequest( run_key=str(datetime.utcnow().timestamp()), run_config={"ops": {"daily_news_html": {"config": {"urls": new_urls}}}}, )Dagster runs the sensor function periodically (default 30 seconds). When it returns a RunRequest, the job triggers.
Asset checks: data quality
Built-in data quality assertions per asset:
from dagster import asset, AssetCheckResult, asset_check @asset def products_data(homepage_html: str) -> list[dict]: # Parse and return products return [{"title": "...", "price": "..."}] @asset_check(asset=products_data) def check_min_products(context, products_data): count = len(products_data) return AssetCheckResult( passed=count >= 10, metadata={"count": count, "expected_min": 10}, ) @asset_check(asset=products_data) def check_no_missing_prices(context, products_data): missing = sum(1 for p in products_data if not p.get("price")) return AssetCheckResult( passed=missing == 0, metadata={"missing_count": missing}, )After every materialization, Dagster runs the checks and shows pass/fail in the UI. Fail an SLA, fail a build, alert the team.
Comparison: Dagster vs Prefect for scraping
dimension Dagster Prefect 3 paradigm software-defined assets flows and tasks best for data pipelines with downstream ETL mid-complexity workflows async support sync-first, async via Op syntax excellent async partitioning rich (multi-dimensional, backfill) basic asset lineage first-class via DAGs hosted option Dagster Cloud Prefect Cloud learning curve medium-high low-medium dashboard excellent excellent Pick Dagster when scraping feeds analytics, ML, or BI. Pick Prefect when scraping is the end product or when async-heavy.
Complete production example
# my_scraper/definitions.py from dagster import ( asset, AssetExecutionContext, Definitions, DailyPartitionsDefinition, define_asset_job, ScheduleDefinition, AssetSelection, IOManager, io_manager, Resource, ) from dagster_aws.s3 import S3PickleIOManager, S3Resource import httpx from selectolax.parser import HTMLParser import json from typing import Optional daily_partitions = DailyPartitionsDefinition(start_date="2026-01-01") @asset(partitions_def=daily_partitions, group_name="ingestion") def raw_pages(context: AssetExecutionContext) -> dict[str, str]: """Fetch all configured pages for the partition date.""" date_str = context.partition_key urls = [ f"https://example.com/products?date={date_str}&page={i}" for i in range(1, 11) ] pages = {} for url in urls: try: resp = httpx.get(url, timeout=30) resp.raise_for_status() pages[url] = resp.text context.log.info(f"Fetched {url}: {len(resp.text)} bytes") except Exception as e: context.log.error(f"Failed {url}: {e}") return pages @asset(partitions_def=daily_partitions, group_name="parsing") def parsed_products(context: AssetExecutionContext, raw_pages: dict) -> list[dict]: """Parse products from all raw pages.""" products = [] for url, html in raw_pages.items(): tree = HTMLParser(html) for el in tree.css("article.product"): title_el = el.css_first("h2") price_el = el.css_first(".price") link_el = el.css_first("a") products.append({ "title": title_el.text(strip=True) if title_el else None, "price": price_el.text(strip=True) if price_el else None, "url": link_el.attributes.get("href") if link_el else None, "source_url": url, "scraped_date": context.partition_key, }) context.log.info(f"Parsed {len(products)} products") return products @asset(partitions_def=daily_partitions, group_name="storage") def products_in_db(context: AssetExecutionContext, parsed_products: list) -> int: """Write parsed products to Postgres.""" import asyncpg import asyncio async def write(): conn = await asyncpg.connect("postgresql://user:pass@localhost/scraper") try: await conn.executemany( """INSERT INTO products (title, price, url, source_url, scraped_date) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (url, scraped_date) DO NOTHING""", [(p["title"], p["price"], p["url"], p["source_url"], p["scraped_date"]) for p in parsed_products], ) return len(parsed_products) finally: await conn.close() count = asyncio.run(write()) context.log.info(f"Stored {count} products to DB") return count @asset_check(asset=parsed_products) def check_minimum_products(context, parsed_products): return AssetCheckResult( passed=len(parsed_products) >= 50, metadata={"count": len(parsed_products), "expected_min": 50}, ) scrape_job = define_asset_job( "daily_scrape", selection=AssetSelection.assets("raw_pages", "parsed_products", "products_in_db"), ) scrape_schedule = ScheduleDefinition( job=scrape_job, cron_schedule="0 6 * * *", execution_timezone="UTC", ) defs = Definitions( assets=[raw_pages, parsed_products, products_in_db], asset_checks=[check_minimum_products], jobs=[scrape_job], schedules=[scrape_schedule], resources={ # Add IO managers, DB connections, etc. }, )This pipeline:
- Fetches raw pages daily, partitioned by date
- Parses products from raw HTML
- Stores to Postgres with idempotent insert
- Asserts minimum product count
- Runs daily at 6am UTC
In the Dagster UI, you see the asset graph, materialization history, and check pass/fail per partition.
Deployment
For production:
- Dagster Cloud (managed): easiest, paid per user + execution
- Self-hosted on Kubernetes: dagster-helm chart, full control
- Self-hosted on a single VM: docker-compose with PostgreSQL, fine for small teams
For Kubernetes:
# values.yaml for dagster-helm dagsterDaemon: enabled: true runLauncher: type: K8sRunLauncher postgresql: enabled: trueDagster spawns a Kubernetes pod per run, isolating resources.
Operational checklist
For production Dagster scraping in 2026:
- Dagster 1.7+ with Python 3.11+
- Dagster Cloud or self-hosted on Kubernetes
- Partitions defined for backfill capability
- IO managers for production storage (S3, Postgres)
- Asset checks for data quality
- Sensors for event-driven workflows
- Schedules for cron-style runs
- Type hints on all asset functions
- Resource configs separate from code
For broader pipeline patterns, see building scraping pipelines with Prefect 3 and distributed scraping with Apache Kafka.
Common pitfalls
- Asset name conflicts: two assets with the same key error out. Use
key_prefixto namespace. - Partition explosion: multi-dimensional partitions can produce millions of cells. Plan partitioning carefully.
- Sync code in async contexts: Dagster is sync-first; for async, use
asyncio.runinside the asset. - Long-running assets: Dagster default timeout is short. Configure execution timeout per asset.
- IO manager mismatches: changing IO managers mid-flight can cause input/output type mismatches.
FAQ
Q: should I use Dagster or Prefect for pure scraping?
For pure scraping, Prefect’s simpler model is usually a better fit. Dagster pays off when scraping is part of a larger ETL or ML pipeline.Q: can I migrate from Airflow to Dagster?
Yes, with effort. The asset model is different from DAGs. Most teams migrate one pipeline at a time over months.Q: does Dagster work with Scrapy?
Yes. Wrap Scrapy invocations as assets. The orchestration value is at the pipeline level, not within individual spiders.Q: how does cost compare to Prefect?
Self-hosted: similar (just compute). Cloud: Dagster Cloud is more expensive per user, but pricing for execution is comparable.Q: is Dagster overkill for a 5-spider project?
Probably yes. For 5 unrelated scrapers, cron + Python scripts is fine. For 5 scrapers feeding a unified analytics warehouse, Dagster shines.Common pitfalls in production Dagster scraping
The first failure mode is the partition backfill blast radius. Dagster’s backfill UI lets you select multiple partitions and re-materialize them. A typo in the partition selector (selecting “all 2026 partitions” when you meant “this week’s partitions”) triggers 365 simultaneous materialization runs, which queues 365 Kubernetes pods, exhausts your cluster’s pod limit, and starves all other pipelines for hours. The fix is to enable backfill concurrency limits in the Dagster instance config, capping simultaneous backfill runs at a sane number:
# dagster.yaml run_coordinator: module: dagster._core.run_coordinator class: QueuedRunCoordinator config: max_concurrent_runs: 25 tag_concurrency_limits: - key: dagster/backfill limit: 5This caps backfills at 5 concurrent runs while leaving 20 slots for scheduled and ad-hoc runs. Without this, a single mis-clicked backfill can take down your entire pipeline cluster.
The second pitfall is asset definition import-time side effects. Dagster imports your asset definitions on every code-server reload to build the asset graph. If your asset definition file contains import-time code that fetches from an external service (loading site configs from a database at module import), every code reload triggers that fetch. With 50 developers reloading code 100 times per day, you generate 5000 daily fetches that the upstream service was never designed for, and you sometimes get throttled by your own configuration store. The fix is to defer all external calls to inside the asset function or use a Dagster Resource that lazy-loads:
from dagster import resource, asset, ConfigurableResource class SiteConfigResource(ConfigurableResource): config_url: str _cache: dict | None = None def get_configs(self) -> dict: if self._cache is None: self._cache = httpx.get(self.config_url).json() return self._cache @asset def raw_pages(context, site_configs: SiteConfigResource): configs = site_configs.get_configs() # only fetched when asset runs # ...The third pitfall is the IO manager memory bloat on large asset values. Dagster’s default IO managers (S3PickleIOManager, FilesystemIOManager) serialize the entire asset value to storage between assets. A
raw_pagesasset returning 10,000 HTML pages of 50KB each is 500MB of data. The pickle serialize-deserialize cycle runs in a single process, peaking at 1GB+ resident memory. Workers OOM-kill, the run fails, and Dagster retries it, blowing through your retry budget. The fix is custom IO managers that stream rather than buffer, or asset partitioning that keeps individual asset values under 100MB:class StreamingS3IOManager(IOManager): def handle_output(self, context, obj): # obj is an iterator, not a list s3_key = f"{context.asset_key.path[-1]}/{context.partition_key}.jsonl" with self.s3_client.open(s3_key, "wb") as f: for item in obj: f.write(json.dumps(item).encode() + b"\n") def load_input(self, context): s3_key = f"{context.asset_key.path[-1]}/{context.partition_key}.jsonl" return (json.loads(line) for line in self.s3_client.iter_lines(s3_key))Then design your assets to yield items rather than return lists. Memory usage drops from O(N) to O(1) per asset, regardless of partition size.
Real-world example: Dagster + dbt for ecommerce intelligence
A scraping team built a Dagster pipeline that scraped 30 ecommerce sites daily and fed a dbt-managed warehouse for downstream analytics. The architecture treated scraped data as Dagster assets that materialized into Snowflake, and dbt models as downstream Dagster assets that ran SQL transformations against those tables:
from dagster import asset, AssetExecutionContext, DailyPartitionsDefinition from dagster_dbt import DbtCliResource, dbt_assets, get_asset_key_for_model from pathlib import Path DBT_PROJECT_DIR = Path(__file__).parent / "ecom_dbt" daily = DailyPartitionsDefinition(start_date="2026-01-01") @asset(partitions_def=daily, group_name="raw_scrape") def raw_amazon_products(context: AssetExecutionContext): date_str = context.partition_key products = scrape_amazon(date=date_str) write_to_snowflake("raw.amazon_products", products) context.add_output_metadata({ "row_count": len(products), "preview": products[:5], }) @asset(partitions_def=daily, group_name="raw_scrape") def raw_walmart_products(context: AssetExecutionContext): date_str = context.partition_key products = scrape_walmart(date=date_str) write_to_snowflake("raw.walmart_products", products) context.add_output_metadata({"row_count": len(products)}) @dbt_assets( manifest=DBT_PROJECT_DIR / "target" / "manifest.json", partitions_def=daily, ) def ecom_dbt_assets(context: AssetExecutionContext, dbt: DbtCliResource): yield from dbt.cli(["build"], context=context).stream()The dbt project contained models like
staging_amazon_products(cleans the raw scrape),mart_competitive_pricing(joins all sites for cross-comparison), andml_features_pricing(rolling windows for the pricing prediction model).In the Dagster UI, the asset lineage showed the full chain: scrape job -> raw tables -> staging models -> mart models -> ML feature tables. When the pricing prediction model returned anomalous results, the team traced the lineage backward and discovered the root cause was a 24-hour gap in the Walmart scrape (the site had pushed an HTML structure change that broke the parser). The lineage view turned a 4-hour debugging session into a 15-minute one.
Asset checks at each layer caught quality issues before they propagated:
@asset_check(asset=raw_amazon_products) def amazon_row_count_check(context, raw_amazon_products): rows = query_snowflake( f"SELECT COUNT(*) FROM raw.amazon_products WHERE scraped_date = '{context.partition_key}'" )[0][0] return AssetCheckResult( passed=rows >= 5000, severity=AssetCheckSeverity.ERROR if rows < 1000 else AssetCheckSeverity.WARN, metadata={"row_count": rows, "expected_min": 5000}, )A check failure marked the partition as unhealthy in the UI and prevented downstream dbt models from running until the issue was resolved. Over six months in production, asset checks caught 47 distinct data quality regressions before they reached the analytics layer.
Comparison: Dagster patterns by scraping scale
A reference table of Dagster patterns that work well at different scraping volumes:
volume pattern partition strategy runtime notes <100 URLs/day single-asset, no partitions none local Dagster simplest 1K-100K URLs/day per-site assets, daily partitions DailyPartitionsDefinition self-hosted sweet spot 100K-10M URLs/day per-site assets, hourly partitions HourlyPartitionsDefinition + multi-dim k8s with autoscale needs IO manager tuning 10M+ URLs/day partitioned ops within assets DynamicPartitionsDefinition Dagster Cloud Hybrid + k8s requires custom IO managers Realtime triggers sensors + asset reconciliation none, sensor-driven self-hosted use Prefect instead if pure realtime For most scraping workloads, the 1K-100K URLs/day band with DailyPartitionsDefinition is the right starting point. Migrate up the table only when partitioning becomes the bottleneck. Migrate down (to a simpler tool) if Dagster’s overhead exceeds your benefit.
Detection: when Dagster is the wrong choice for scraping
Five signals that your scraping workload should NOT live on Dagster:
- Pure scraping with no downstream ETL: if all you do is scrape and dump to JSON files, Dagster’s asset model is overhead. Use Prefect or cron + scripts.
- Heavy async workload (1000+ concurrent requests): Dagster is sync-first. Heavy async work runs better in Prefect or Celery.
- Sub-minute scheduling: Dagster’s minimum schedule interval is one minute. Sub-minute requires sensors or external triggers.
- Fluid pipeline shape that changes per run: Dagster’s asset model assumes a stable graph. Per-run dynamic shape is awkward.
- Real-time event-driven only: pure event-driven workloads work better in event-streaming systems (Kafka, NATS) than in orchestrators.
If three or more of these apply, Prefect or a simpler tool is the right answer.
Performance tuning: parallel asset execution
Dagster’s default behavior runs assets sequentially within a job. For scraping where assets are independent (different sites), enable multi-process or k8s execution:
from dagster import multiprocess_executor, define_asset_job scrape_job = define_asset_job( "all_sites", selection="*", executor_def=multiprocess_executor.configured({ "max_concurrent": 8, }), )This runs up to 8 site-scraping assets in parallel within one job run. For Kubernetes-based execution, use
k8s_job_executorwhich spawns one pod per asset:from dagster_k8s import k8s_job_executor scrape_job = define_asset_job( "all_sites", executor_def=k8s_job_executor.configured({ "max_concurrent": 30, "container_config": { "resources": { "requests": {"memory": "1Gi", "cpu": "500m"}, "limits": {"memory": "4Gi", "cpu": "2000m"}, }, }, }), )Per-asset isolation prevents one runaway scrape from OOM-killing the entire job. The trade-off is pod startup latency (5-15 seconds per asset), which is fine for hourly or daily jobs but punitive for sub-minute schedules.
Wrapping up
Dagster excels when scraping is one node in a larger data pipeline. The software-defined asset model gives lineage, partitioning, and freshness tracking that pure orchestrators do not. For teams that already think in terms of data products and downstream consumers, it pays off. Pair this with our building scraping pipelines with Prefect 3 and distributed scraping with Apache Kafka writeups for the full pipeline picture, and browse the dev-tools-projects category on DRT for related infrastructure deep-dives.
-
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.