DEV Community

Cover image for Stop Scraping DOMs When JSON APIs Are Hiding in Plain Sight: Benchmarks with Async Python & Playwright
Trung Thanh Tran
Trung Thanh Tran

Posted on Edited on

Stop Scraping DOMs When JSON APIs Are Hiding in Plain Sight: Benchmarks with Async Python & Playwright

Every week I audit broken data pipelines where an engineering team is spending hundreds of dollars a month running containerized headless Chromium clusters just to extract product prices, real estate listings, or B2B directory profiles.

When you open their codebase, the pattern is almost always identical:

  1. Launch a full browser context per worker (350–500 MB RSS memory each).
  2. Download 2.4 MB of JavaScript bundles, web fonts, and hero images.
  3. Wait 4,000 ms for React or Vue to hydrate a JSON payload into <div> nodes.
  4. Run brittle CSS selectors (.card_wrapper__x92a > span:nth-child(2)) to turn those <div> nodes back into JSON.

Think about step 3 and step 4 together. The server already serialized clean, typed JSON from its database. The browser burned CPU cycles turning that JSON into HTML, and your scraper burned even more CPU cycles reverse-engineering HTML back into JSON—while breaking every time a frontend engineer updates a Tailwind or CSS-Modules hash.

In production data engineering, headless browsers should be your network-discovery tool and auth-handoff bridge, not your primary HTML parser.

Here is the exact 4-Tier Extraction Waterfall I use across commercial scrapers and client pipelines, backed by benchmarks across 1,000 paginated catalog requests and complete Python 3.11 implementations.


The Empirical Cost of Rendering the DOM

To quantify the gap, let's measure four common architectures fetching 1,000 paginated e-commerce/directory pages (20,000 structured records total) on a standard 2 vCPU / 4 GB RAM Linux worker:

Extraction Strategy Median Latency / Page Total Wall Time (1,000 Pages, 10 Workers) Peak Worker RSS Memory Selector Breakage Risk
1. Playwright Full Browser + CSS Selectors 3,480 ms ~5 min 52 sec ~1,850 MB (capped at 4 workers) High (UI class renames)
2. Playwright (route.abort Media/Fonts) 1,420 ms ~2 min 28 sec ~1,120 MB High (UI class renames)
3. Next.js __NEXT_DATA__ / JSON-LD via httpx 115 ms ~12.4 sec ~31 MB Low (Schema-backed)
4. Direct XHR / Cursor JSON API via Async httpx 68 ms ~7.1 sec ~24 MB Minimal (Backend DTO)

Switching from DOM-rendered Chromium scraping to direct async httpx JSON pagination improves throughput by 51.2x while cutting memory usage by 94%. Even better, lightweight 256 MB serverless containers (such as Apify Python Actors or AWS Lambda functions) can run the entire job for fractions of a cent.


The 4-Tier Extraction Waterfall

Before writing a single page.locator() or BeautifulSoup CSS query, inspect every target domain through four progressively heavier tiers:

Tier 1: application/ld+json (Schema.org) & Sitemaps

Any site that cares about Google Search rich snippets—Shopify stores, WooCommerce catalogs, healthcare directories, hospitality platforms, job boards—embeds machine-readable Schema.org entities right inside <script type="application/ld+json">.

Unlike visual DOM classes, marketing teams fight against breaking JSON-LD because breaking it destroys their Google Shopping and Local Pack rankings.

import json
from typing import Any, Iterator
from bs4 import BeautifulSoup


def iter_json_ld_entities(html: str) -> Iterator[dict[str, Any]]:
    """Extracts flattened Schema.org entities, handling nested @graph arrays."""
    soup = BeautifulSoup(html, "html.parser")
    for tag in soup.find_all("script", attrs={"type": "application/ld+json"}):
        raw = (tag.string or tag.get_text() or "").strip()
        if not raw:
            continue
        try:
            payload = json.loads(raw)
        except json.JSONDecodeError:
            continue

        stack = payload if isinstance(payload, list) else [payload]
        while stack:
            node = stack.pop()
            if not isinstance(node, dict):
                continue
            if isinstance(node.get("@graph"), list):
                stack.extend(node["@graph"])
            if "@type" in node:
                yield node
Enter fullscreen mode Exit fullscreen mode

On Shopify stores, combining /products.json?limit=250&page=1 with Product JSON-LD gives you exact SKU availability, currency codes, compare-at discounts, and GTIN/MPN identifiers without executing a single line of client JavaScript. (I use this exact dual-tier approach in my open-source E-Commerce Price & Stock Monitor Actor).


Tier 2: SSR Hydration Payloads (__NEXT_DATA__, __NUXT__, RSC Flight)

Frameworks like Next.js, Nuxt, and Remix fetch data on the server to render SEO-friendly HTML, then serialize the exact server props into the document so client-side React doesn't have to refetch on mount.

  • Next.js Pages Router: Look for <script id="__NEXT_DATA__" type="application/json">. Even better, once you grab the buildId from __NEXT_DATA__, subsequent page navigations are pure JSON endpoints at /_next/data/<buildId>/catalog.json?page=2!
  • Next.js App Router (React Server Components): Inspect self.__next_f.push([1, "..."]) chunks or send the RSC: 1 header to inspect Flight stream data.

Tier 3: Automated XHR / GraphQL API Discovery with Playwright

What if the initial HTML shell is empty and data loads dynamically on scroll or filter clicks? Instead of manually clicking through Chrome DevTools for 20 minutes, run a 30-line Playwright XHR Sniffer once during development to map every JSON endpoint the page calls:

import asyncio
from playwright.async_api import async_playwright, Response


async def sniff_json_endpoints(target_url: str) -> None:
    """Captures background JSON XHR/Fetch endpoints, status codes, and payload keys."""
    discovered: list[dict] = []

    async def on_response(response: Response) -> None:
        ctype = response.headers.get("content-type", "").lower()
        if response.request.resource_type in ("xhr", "fetch") and "json" in ctype:
            try:
                body = await response.json()
                top_keys = list(body.keys())[:6] if isinstance(body, dict) else f"list[{len(body)}]"
                discovered.append({
                    "method": response.request.method,
                    "url": response.url,
                    "status": response.status,
                    "keys": top_keys,
                    "auth_headers": {
                        k: v for k, v in response.request.headers.items()
                        if k.lower() in ("authorization", "x-api-key", "x-csrf-token")
                    },
                })
            except Exception:
                pass

    async with async_playwright() as p:
        browser = await p.chromium.launch(headless=True)
        page = await browser.new_page()
        page.on("response", on_response)

        # Abort heavy visual assets so discovery finishes in <3 seconds
        await page.route(
            "**/*.{png,jpg,jpeg,webp,svg,woff,woff2,mp4}",
            lambda route: route.abort(),
        )
        await page.goto(target_url, wait_until="networkidle")
        await browser.close()

    for hit in discovered:
        print(f"[{hit['status']}] {hit['method']} {hit['url']}\n    Keys: {hit['keys']} | Auth: {hit['auth_headers']}")


if __name__ == "__main__":
    asyncio.run(sniff_json_endpoints("https://example-directory.com/search"))
Enter fullscreen mode Exit fullscreen mode

In real client engagements—such as scraping licensed housing developers from Malaysia's government portal (apify-malaysia-kpkt-teduh-property-scraper)—this exact sniffer immediately reveals clean backend JSON routes like /api/developer-swasta?page=1 returning 50 nested records per call, complete with fields that aren't even visible in the UI table until you click a modal.


Tier 4: Production Async httpx Client with Backoff & Hybrid Cookie Handoff

Once you know the JSON endpoint, don't hammer it with naive asyncio.gather(*[client.get(u) for u in thousand_urls]). Unbounded concurrency triggers WAF rate limits (429 Too Many Requests), exhausts ephemeral TCP sockets, and gets your IP banned.

A production async extractor needs three guardrails:

  1. Bounded concurrency via asyncio.Semaphore.
  2. Connection pooling via a single shared httpx.AsyncClient(http2=True).
  3. Exponential backoff with full jitter respecting Retry-After headers.
import asyncio
import random
from typing import Any
import httpx


class ResilientJSONExtractor:
    def __init__(
        self,
        concurrency: int = 8,
        max_retries: int = 4,
        cookies: dict[str, str] | None = None,
        extra_headers: dict[str, str] | None = None,
    ) -> None:
        self.sem = asyncio.Semaphore(concurrency)
        self.max_retries = max_retries
        self.headers = {
            "User-Agent": (
                "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
                "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"
            ),
            "Accept": "application/json, text/plain, */*",
            **(extra_headers or {}),
        }
        self.cookies = cookies or {}

    async def fetch_page(self, client: httpx.AsyncClient, url: str, params: dict[str, Any]) -> dict[str, Any]:
        async with self.sem:
            for attempt in range(1, self.max_retries + 1):
                try:
                    resp = await client.get(url, params=params)
                    if resp.status_code == 200:
                        return resp.json()

                    if resp.status_code in (429, 502, 503, 504):
                        retry_after = float(resp.headers.get("Retry-After", 0))
                        backoff = max(retry_after, (2 ** (attempt - 1)) + random.uniform(0.1, 0.8))
                        await asyncio.sleep(backoff)
                        continue

                    resp.raise_for_status()
                except (httpx.TransportError, httpx.TimeoutException):
                    if attempt == self.max_retries:
                        raise
                    await asyncio.sleep((2 ** (attempt - 1)) + random.uniform(0.1, 0.6))
            raise RuntimeError(f"Exhausted {self.max_retries} retries for {url}")

    async def paginate_cursor(self, base_url: str, max_pages: int = 50) -> list[dict[str, Any]]:
        limits = httpx.Limits(max_keepalive_connections=20, max_connections=30)
        records: list[dict[str, Any]] = []
        async with httpx.AsyncClient(
            headers=self.headers,
            cookies=self.cookies,
            timeout=httpx.Timeout(15.0, connect=5.0),
            limits=limits,
            follow_redirects=True,
        ) as client:
            tasks = [self.fetch_page(client, base_url, {"page": p, "limit": 50}) for p in range(1, max_pages + 1)]
            pages = await asyncio.gather(*tasks, return_exceptions=True)
            for payload in pages:
                if isinstance(payload, dict) and isinstance(payload.get("data"), list):
                    records.extend(payload["data"])
        return records
Enter fullscreen mode Exit fullscreen mode

When You Do Need Playwright: The Hybrid Session Handoff Pattern

Sometimes pure httpx hits a wall on request #1:

  • Short-lived signed session cookies minted by a client-side JavaScript challenge.
  • OAuth / SSO login flows with dynamic CSRF tokens or MFA steps.
  • GraphQL persistedQuery hashes accompanied by rotating x-client-transaction-id headers.

Even then, you still don't need Playwright to render 10,000 detail pages! Use Hybrid Session Handoff:

  1. Launch Playwright once at job startup (~3 seconds).
  2. Complete authentication or let the challenge cookie settle.
  3. Export context.cookies() into a dict ({c["name"]: c["value"] for c in await context.cookies()}) and shut down Chromium immediately.
  4. Feed those cookies into ResilientJSONExtractor and run the remaining 9,999 JSON requests at 68 ms / page inside 25 MB of RAM.

If cookies expire every 20 minutes, wrap the handoff in an async token-refresh lock that only re-launches Playwright on a 401/403 status code.


Putting It Into Practice (Open-Source Reference Repos)

If you want to inspect or fork complete production pipelines built on this exact philosophy, check out these repositories:

Next time someone hands you a slow, crashing Selenium or Puppeteer script, pop open the Fetch/XHR tab first. Nine times out of ten, the backend API you wish existed is already right there waiting for you.


I'm Trung Thanh Tran, a Python, Playwright & AI Automation Engineer building high-reliability web extraction engines and workflow systems.

  • Want 6 ready-to-run Python 3.11 CLI engines (including a 4-Tier Universal Web & JSON Extractor) + 3 importable n8n/Make blueprints? Grab my Python, AI & n8n Scraper Toolkit Pro on Gumroad.
  • Need a custom scraper, anti-bot audit, or end-to-end automation pipeline? Reach out on Upwork or Fiverr.

Top comments (1)

Collapse
 
lucas_him_f7e05624dd9cc96 profile image
Lucas Him •

In paginate_cursor, gather(return_exceptions=True) lets a failed page disappear from the output while successful pages still look like a complete catalog. Could you return failed page IDs and a completeness flag alongside records, and keep the previous price-monitoring baseline whenever any page fails? Otherwise a transient timeout can look like products were removed.