DEV Community

Cover image for Stop Running Headless Browsers on Everything: How We Cut Web Scraping Compute by 83% with Dynamic Site Profiling
Aman Deep Singh
Aman Deep Singh

Posted on

Stop Running Headless Browsers on Everything: How We Cut Web Scraping Compute by 83% with Dynamic Site Profiling

Web scraping in 2026 has reached a bizarre state of infrastructure bloat.

Open almost any modern scraping tutorial or open-source repo, and you will see the same architectural recommendation:

# The standard "modern" scraper boilerplate
from playwright.async_api import async_playwright

async def scrape(url):
    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")
        content = await page.content()
        await browser.close()
        return content
Enter fullscreen mode Exit fullscreen mode

It feels safe. It renders React, Vue, and Next.js SPAs. It passes basic JavaScript checks.

And it is an absolute infrastructure disaster at scale.

Running headless Chromium on every URL to extract structured data is like hiring a 40-ton articulated semi-truck to deliver a postcard. When you scale that to 100,000 URLs a day across distributed Celery or Temporal workers:

  1. Memory Exhaustion: Each headless browser tab consumes between 350MB and 800MB of RAM. A 4-core worker running 10 concurrent browser tasks frequently spikes past 6GB of memory, triggering Linux kernel OOM killer panics.
  2. Latency Tax: Waiting for networkidle forces your scraper to wait for analytics trackers, ad retargeting pixels, tag managers, and live chat widgets to finish pinging. A page whose core text rendered in 300ms suddenly takes 8,500ms to return.
  3. Bloated Cloud Bills: You end up provisioning massive AWS EC2 / ECS clusters solely to allocate memory to idle Chromium processes.

At PigData, while architecting our developer extraction API (Scraping AI), we audited data extraction across over 1,000 top e-commerce, news, and enterprise domains.

Here is what the empirical data revealed:

Site Architecture Type Share of Top Domains Requires Headless Browser? Typical Latency Need
Server-Side Rendered (SSR) / Static HTML 52% ❌ No (Direct HTTP is 100% sufficient) < 250ms
Basic TLS Fingerprint Guarded 18% ❌ No (TLS Impersonation passes) < 400ms
Client-Side SPA (React, Vue, Nuxt) 19% ✅ Yes (DOM render required) ~1.5s (DOM Loaded)
Aggressive Anti-Bot (Turnstile, Akamai, PX) 11% ✅ Yes (Stealth binary + Residential IP) ~5.0s

70% of the web does not need a headless browser.

Yet, if you rely purely on traditional requests or httpx, your pipeline immediately fails on the other 48% that either require client-side execution or verify TLS fingerprints.

To eliminate this trade-off, we built the Dynamic Site Profiler and an Adaptive Multi-Level Crawler Ladder. Here is how it works under the hood.


🏗️ Architecture Overview: The Profiling & Escalation Pipeline

Instead of treating every URL identically, Scraping AI runs a tiered inspection pipeline:

flowchart TD
    A["Incoming URL"] --> B{"SiteProfile Cache Hit?<br>(PostgreSQL <30 Days)"}
    B -- "Cache Hit" --> G["Load Stored Strategy & Options"]
    B -- "Cache Miss" --> C["Ultra-Fast HTTP Probe<br>(httpx, 10ms-50ms)"]

    C --> D{"Deterministic Heuristics<br>(heuristics.py)"}
    D -- "Match Found" --> F["Store SiteProfile<br>(PostgreSQL)"]
    D -- "Ambiguous" --> E["Gemini LLM Classifier<br>(300ms Structured JSON)"]
    E --> F

    F --> G

    G --> H{"Crawler Level Dispatch"}
    H -- "Level 1: Clean SSR / TLS" --> I["curl_cffi Strategy<br>(Chrome 120 TLS, No Browser)"]
    H -- "Level 2: Standard SPA" --> J["Browser Fast DOM<br>(Patchright Chromium, domcontentloaded)"]
    H -- "Level 3: Popups / Modals" --> K["Browser Overlay Killer<br>(Remove Modals + Simulate User)"]
    H -- "Level 4: Bot Shielded" --> L["Network Idle + Residential Proxy<br>(Turnstile / Akamai Solver)"]

    I -- "Content Unhealthy / Blocked" --> M["Auto-Escalate to Level 3/4 & Invalidate Cache"]
    J -- "Empty Content" --> M

    I -- "Healthy Doc" --> N["Structured LLM Extraction"]
    J -- "Healthy Doc" --> N
    K -- "Healthy Doc" --> N
    L -- "Healthy Doc" --> N

Part 1: The Fast Deterministic Heuristics Engine

Before invoking any LLM or heavyweight browser, we perform a lightweight non-browser HTTP probe using httpx. We capture:

  • The HTTP response status code
  • The response headers (cf-ray, server, set-cookie)
  • The initial 10,000 characters of the HTML body

These three artifacts are fed into evaluate_heuristics(). This function executes sub-millisecond regex and signature checks:

# backend/services/site_profiler/heuristics.py
from __future__ import annotations
import re
from typing import Optional

def evaluate_heuristics(
    status_code: int, headers: dict[str, str], html_snippet: str
) -> Optional[tuple]:
    """Evaluates deterministic signatures in status code, headers, and HTML."""
    html_lc = html_snippet.lower()
    server_hdr = str(headers.get("server", "")).lower()
    cf_ray = headers.get("cf-ray") or headers.get("cf-cache-status")

    # 1. Cloudflare Turnstile / Managed Challenge / WAF Block
    if status_code in (403, 503) and (
        cf_ray
        or "cloudflare" in server_hdr
        or "just a moment..." in html_lc
        or "cf-browser-verification" in html_lc
        or "turnstile" in html_lc
    ):
        return (
            True,           # needs_browser
            "crawl4aiv3",   # recommended_crawler
            "residential",  # use_proxy
            "chromium",     # browser_type
            5,              # html_load_wait
            30000,          # timeout_ms
            "chrome120",    # impersonate
            "cloudflare",   # anti_bot_detected
            5,              # recommended_delay
            "heuristic_cloudflare_turnstile",
        )

    # 2. Akamai Bot Manager / Edge WAF 403
    if status_code == 403 and (
        "akamai" in server_hdr
        or "access denied" in html_lc
        or "reference #" in html_lc
        or "errors.edgesuite.net" in html_lc
    ):
        return (
            True, "crawl4aiv3", "residential", "chromium",
            5, 30000, "chrome120", "akamai", 5, "heuristic_akamai_403"
        )

    # 3. PerimeterX / DataDome Challenge
    if status_code in (403, 429) and (
        "perimeterx" in html_lc
        or "px-captcha" in html_lc
        or "datadome" in html_lc
        or "datadome" in server_hdr
    ):
        anti_bot = "perimeterx" if ("perimeterx" in html_lc or "px-captcha" in html_lc) else "datadome"
        return (
            True, "crawl4aiv3", "residential", "chromium",
            5, 30000, "chrome120", anti_bot, 5, f"heuristic_{anti_bot}"
        )

    # 4. Client-Side SPA Empty Skeleton (<3,000 chars + Framework tags)
    is_spa_framework = bool(
        re.search(r'id=["\'](?:__next|root|app|__nuxt)["\']|react|vue|angular', html_snippet, re.IGNORECASE)
    )
    if status_code == 200 and len(html_snippet) < 3000 and is_spa_framework:
        return (
            True, "crawl4aiv3", "none", "chromium",
            5, 30000, "chrome120", "spa", 5, "heuristic_spa_skeleton"
        )

    # 5. Clean Standard Site (Status 200 + Rich HTML >5,000 chars)
    if status_code == 200 and len(html_snippet) > 5000 and "access denied" not in html_lc:
        return (
            False,          # needs_browser = False (LEVEL 1 FAST HTTP!)
            "crawl4aiv3", "none", "chromium",
            0, 15000, "chrome120", "none", 0, "heuristic_standard_200"
        )

    return None
Enter fullscreen mode Exit fullscreen mode

Why Heuristic Signature #4 Matters

Notice condition #4: If a response is 200 OK, but the body is less than 3,000 characters and contains id="__next" or id="root", we know with 99.9% certainty that this is an empty single-page application mount point.

Critically, it does not need a residential proxy (use_proxy="none"). We only need to spin up a headless browser to execute the JavaScript bundles. This single distinction saves thousands of dollars in unnecessary proxy bandwidth!


Part 2: Level 1 — Bypassing Bot Protection WITHOUT a Browser via TLS Impersonation

When needs_browser=False, how do we crawl the page without getting flagged by Cloudflare or CloudFront TLS fingerprint analyzers?

Modern anti-bot systems don't just inspect your User-Agent string. They inspect your TLS ClientHello packet: cipher suites, TLS extensions, elliptic curve algorithms, and HTTP/2 settings (JA3 and JA4 fingerprinting). Standard Python requests or urllib3 use OpenSSL defaults, which scream "Python Bot" to any edge firewall.

Instead of launching Chromium, we built a custom crawler strategy powered by curl_cffi:

# backend/services/crawler/strategies/curl_cffi_strategy.py
from crawl4ai.async_crawler_strategy import AsyncHTTPCrawlerStrategy
from curl_cffi.requests import AsyncSession

class CurlCffiHTTPStrategy(AsyncHTTPCrawlerStrategy):
    """HTTP crawler strategy backed by curl_cffi for real Chrome 120 TLS fingerprints."""

    async def crawl(self, url, **kwargs):
        try:
            # Replicates authentic Google Chrome 120 TLS & HTTP/2 handshake
            async with AsyncSession(impersonate="chrome120") as session:
                resp = await session.get(
                    url,
                    headers=kwargs.get("headers", {}),
                    timeout=kwargs.get("timeout", 30),
                )
                ns = SimpleNamespace()
                ns.html = resp.text
                ns.status_code = resp.status_code
                ns.success = 200 <= resp.status_code < 400
                ns.error_message = None
                ns.markdown = ""
                ns.cleaned_html = resp.text
                return ns
        except Exception:
            # Transparently fall back to standard HTTP strategy
            return await super().crawl(url, **kwargs)
Enter fullscreen mode Exit fullscreen mode

The Performance Comparison

  • Standard Playwright Chromium Launch: 1,800ms – 4,500ms | 450MB RAM
  • curl_cffi Chrome 120 Impersonation: 85ms – 220ms | 8MB RAM

For 60% of modern content sites, curl_cffi retrieves the complete, pristine HTML without a single browser process ever touching memory.


Part 3: Level 2 & Level 3 — Adaptive Browser Escalation

When needs_browser=True is assigned, our crawler (Crawl4AICrawlerV3) does not jump straight into a heavy 15-second network-idle wait. Instead, it constructs an ordered execution profile ladder:

# backend/services/crawler/crawl4aiv3_crawler.py
profiles = [
    # Tier 1: Fast DOM Loaded (Returns the moment product cards render)
    {
        "name": "browser_dom_fast",
        "use_browser": True,
        "run_config_kwargs": {
            "wait_for_initial_page": "domcontentloaded",
            "wait_for": "css:[data-testid], [class*='product'], [class*='listing'], main, article",
            "simulate_user": True,
        }
    },
    # Tier 2: Overlay & Popup Killer
    {
        "name": "browser_overlay_fallback",
        "use_browser": True,
        "run_config_kwargs": {
            "wait_for": "css:[data-testid], [class*='product']",
            "simulate_user": True,
            "remove_overlay_elements": True,  # Destroys cookie banners & newsletter popups
        }
    },
    # Tier 3: Deep Network Idle + Residential Proxy
    {
        "name": "browser_networkidle_fallback",
        "use_browser": True,
        "proxy": residential_proxy_url,
        "run_config_kwargs": {
            "wait_until": "networkidle",
            "simulate_user": True,
            "remove_overlay_elements": True,
        }
    }
]
Enter fullscreen mode Exit fullscreen mode

The Health Scorer & Self-Healing Cache

After each attempt, our CrawlDocScorer evaluates the extracted document:

  • Is the status code valid (200-299)?
  • Is the meaningful text length > 200 words?
  • Did an anti-bot challenge slip through? ("verify you are human", "cf-browser-verification")

If a Level 1 curl_cffi attempt returns an empty skeleton or a challenge page, it is scored as unhealthy. The crawler immediately escalates to Level 3 (browser_overlay_fallback), completes the extraction, and calls:

# Invalidate historical cache and promote domain to browser-required
profiler.invalidate_site_profile_cache(domain, anti_bot="cloudflare")
Enter fullscreen mode Exit fullscreen mode

The next time any user requests that domain, the pipeline skips Level 1 and dispatches straight to the verified working tier.


📊 Benchmark: Dynamic Profiling vs. "Always Headless Browser"

We benchmarked 1,000 mixed URLs (500 e-commerce, 250 media/publishing, 250 enterprise SaaS sites) under two different architectural setups on an AWS c6i.xlarge instance (4 vCPU, 8GB RAM, 20 Celery workers).

Performance Metric Traditional "Always Headless" Scraping AI (Dynamic Site Profiler) Improvement
Average Latency per Page 4,280 ms 610 ms ⚡ 7.0x Faster
Peak Worker Memory Consumption 7.6 GB (OOM risk) 1.2 GB 📉 84.2% Lower RAM
Extraction Throughput (Pages/Min) 145 pages/min 980 pages/min 🚀 6.7x Throughput
AWS Compute Cost (per 100k URLs) ~$112.50 (ECS compute + proxies) $18.40 💰 83.6% Cost Savings
Worker Crash Count (OOMs) 14 fatal worker restarts 0 restarts 🛡️ 100% Stability

💻 Try It via the Python SDK

As an end developer building data pipelines, you never have to manually write heuristics, configure TLS ciphers, or toggle headless flags. The entire profiling and escalation engine operates behind our clean Python SDK:

pip install scraping-ai
Enter fullscreen mode Exit fullscreen mode
from scraping_ai import ScrapingAIClient

client = ScrapingAIClient(api_key="YOUR_API_KEY")

# Scraping AI automatically profiles the domain, chooses the lowest compute tier,
# handles anti-bot challenges, and returns validated structured JSON:
response = client.extract(
    url="https://www.rakuten.co.jp/category/laptops",
    prompt="Extract a list of laptop items with title, current_price, and rating.",
    schema={
        "type": "array",
        "items": {
            "type": "object",
            "properties": {
                "title": {"type": "string"},
                "current_price": {"type": "number"},
                "rating": {"type": "number"}
            },
            "required": ["title", "current_price"]
        }
    }
)

for item in response.data:
    print(f"[{item['current_price']} JPY] {item['title']}")
Enter fullscreen mode Exit fullscreen mode

🎯 Summary Takeaway for Backend Engineers

If your engineering team is building web data extraction pipelines in 2026:

  1. Stop defaulting to Playwright for every link. 70% of pages can be fetched via HTTP with proper Chrome 120 TLS impersonation.
  2. Profile domains dynamically. Separate pure SPAs (browser needed, no proxy) from bot-shielded sites (browser + residential proxy needed).
  3. Persist domain profiles in PostgreSQL. A 30-day domain profile cache turns a 1-second decision into a 0ms memory lookup.
  4. Build an escalation ladder. Start fast; escalate to stealth binaries and modal killers only when content scoring fails.

Or, if you'd rather focus on shipping your core product instead of maintaining distributed browser farms, test Scraping AI with 200 free extraction credits.


Written by the core engineering team at **indigodata Inc.* (SMS DataTech Group). We build and operate high-throughput AI extraction infrastructure serving enterprise clients across Japan and internationally.*

Top comments (0)