DEV Community

Cover image for Rendering JavaScript at Scale Without a Browser Farm: An Architecture Walkthrough
PromptCloud
PromptCloud

Posted on

Rendering JavaScript at Scale Without a Browser Farm: An Architecture Walkthrough

The reflex when a site is JavaScript-heavy is to spin up headless Chrome and scale it horizontally. That works, and it is also the most expensive, slowest, most fragile way to solve the problem. Here is an architecture that renders only the pages that genuinely need it, and renders those lean, so a small pool does the work of a farm.

If you have ever built a crawler for a modern site, you know the moment: the HTML comes back and the content you wanted is not in it. It is a single-page app, the DOM is assembled in the browser, and requests plus a parser gets you an empty shell. The obvious fix is to render the page in a real browser, and the obvious way to do that at scale is to run a lot of browsers. That is the browser farm, and it is where a lot of scraping infrastructure quietly goes to die: headless Chrome is memory-hungry, slow relative to an HTTP call, and every version bump or anti-automation change means babysitting fleets of instances.

The good news is that most of the time you do not need it. A JavaScript-heavy page is heavy in the browser, but the data underneath it almost always arrives in a form you can get to without rendering anything. The architecture below is built around one principle: full rendering is a last resort, not a default, and the job of the system is to route each URL to the cheapest tier that actually works.

The principle: rendering is a cost, not a step

Treat a full browser render as the most expensive operation in your pipeline, because it is. An HTTP request costs milliseconds and a few kilobytes of memory. A rendered page costs hundreds of milliseconds to seconds, tens to hundreds of megabytes, and a browser process you have to manage. If you render every JS page by default, you have priced your entire crawl at the rate of your single most expensive operation. The whole design goal is to push as much traffic as possible into cheaper tiers and reserve the browser for the residue that has no other route.

Tier 0: find the API the page is already calling

This is the single biggest lever, and it is the one most often skipped. A single-page app does not conjure content from nothing. It renders by calling a backend, almost always over fetch or XHR, and almost always getting back clean JSON. The browser assembles that JSON into a DOM. You do not have to. You can call the same endpoint the page calls and parse the JSON directly, which is faster, lighter, and usually more stable than scraping rendered HTML.

Finding it is a one-time investigation per site: open the network panel, filter to XHR/fetch, and watch what the page requests as it loads the data you want. You are looking for the request whose response contains your fields. Once you have it, you replicate its method, headers, and parameters in a plain HTTP client. This is the core of most modern crawling techniques for JavaScript-heavy websites: the render is a distraction, and the real target is the data feed behind it.

Two caveats keep this honest. Some endpoints are protected by tokens or signatures the page generates in JavaScript, in which case you may need a light render once to obtain a token, then reuse it. And some are paginated or shaped awkwardly. Neither changes the principle: an API call, even an occasionally-primed one, is an order of magnitude cheaper than rendering every page.

Tier 1: read the state the page shipped in its HTML

When there is no live endpoint to call, the data is very often sitting in the initial HTML already, embedded as JSON for the framework to hydrate from. This is server-side rendering and hydration, and it leaves fingerprints you can parse without a browser:

import json
from bs4 import BeautifulSoup

def extract_embedded_state(html: str):
    soup = BeautifulSoup(html, "lxml")

    # Next.js ships page props here
    nextjs = soup.find("script", id="__NEXT_DATA__")
    if nextjs:
        return json.loads(nextjs.string)

    # Common hand-rolled and Nuxt/Vue patterns live in inline scripts
    for script in soup.find_all("script"):
        text = script.string or ""
        for marker in ("__INITIAL_STATE__", "__NUXT__", "__APOLLO_STATE__"):
            if marker in text:
                start = text.index("{")
                return json.loads(text[start:text.rindex("}") + 1])

    # Structured data is a gift when it is present
    ld = soup.find("script", type="application/ld+json")
    if ld:
        return json.loads(ld.string)

    return None
Enter fullscreen mode Exit fullscreen mode

NEXT_DATA, window.INITIAL_STATE, NUXT, APOLLO_STATE, and application/ld+json blocks cover a large share of the framework-built web. When one of them holds your fields, you have the fully-formed data with a single HTTP request and a JSON parse, and you never opened a browser.

Tier 2: render, but only what is left, and only leanly

Some pages defeat both tiers above: the data is fetched by a request you cannot easily replay, assembled across several calls, or gated behind interaction. These genuinely need a browser. The mistake is to let that small residue dictate the cost of the whole system by running heavyweight, general-purpose browser instances for it.

Render as a shared, stateless service behind a queue, and make each render as cheap as you can. Three things do most of the work: reuse one browser across many pages via separate contexts rather than launching a process per URL, block every resource that does not contribute to the data you need, and put a hard budget on every render so a slow page cannot stall the pool.

from playwright.async_api import async_playwright

BLOCK = {"image", "media", "font", "stylesheet"}

async def render(url: str, wait_for: str, browser):
    context = await browser.new_context()  # cheap; isolate, then discard
    page = await context.new_page()

    # Drop everything that does not produce data: images, fonts, CSS,
    # analytics and ad calls. This alone cuts render time and memory hard.
    async def gate(route):
        req = route.request
        if req.resource_type in BLOCK or "analytics" in req.url:
            await route.abort()
        else:
            await route.continue_()
    await page.route("**/*", gate)

    try:
        await page.goto(url, wait_until="commit", timeout=15000)
        await page.wait_for_selector(wait_for, timeout=8000)
        return await page.content()
    finally:
        await context.close()  # hard budget: never leak a context
Enter fullscreen mode Exit fullscreen mode

Blocking images, fonts, stylesheets, media, and third-party analytics or ad requests routinely removes the majority of a page's bytes and a large slice of its render time, because you are no longer downloading and painting things no parser will ever read. Reusing one browser across isolated contexts avoids the per-process launch cost that makes naive farms so heavy. And a strict wait_for_selector with timeouts means you wait for the specific element that signals your data has arrived, not for some arbitrary sleep, and you fail fast when it does not. The residue still costs more than an HTTP call, but a lean render on a shared pool is a different order of expense from a farm of full browsers.

The routing layer: decide once per template, not once per URL

The tiers only save money if something cheap decides which tier a URL needs, and the key insight is that you almost never decide per URL. Sites are template-driven. Every product page on a site is the same page with different data, so if one product page exposes a JSON endpoint at Tier 0, they all do. The expensive discovery, finding the endpoint or the embedded-state key, happens once per template, and the result is cached against a URL pattern.

So the router works like this: classify a URL to its template (by path shape or a learned pattern), look up the known route for that template, and apply it directly, HTTP-only for Tier 0 and 1, queued render for Tier 2. Only when a template is unseen, or its cached route starts failing, do you run the one-off investigation and update the cached decision. In steady state almost every request is served by a cached routing decision, and only a trickle triggers rediscovery. This is also where a self-healing loop lives: a route that begins returning empty or malformed data flags the template for re-investigation rather than silently shipping bad rows.

Caching, dedup, and the numbers that result

Two more cheap wins sit on top. Deduplicate in-flight requests so you never render the same URL twice concurrently, and cache tier outputs with a sensible freshness window so repeat crawls of slow-changing pages skip work entirely.

Put it together and the economics invert. Instead of rendering 100% of JS pages, a mature pipeline of this shape typically serves the large majority of traffic from Tier 0 and Tier 1 at HTTP cost, and renders only the remainder, leanly, on a small shared pool. You have replaced a browser farm with an HTTP-first pipeline that happens to keep a modest rendering service on call. That is cheaper to run, faster end to end, and far less fragile, because most of your crawl no longer depends on the most breakable component you own.

The takeaway

A JavaScript-heavy site is not a rendering problem, it is a routing problem. The data is nearly always reachable without a browser, through the API the page calls or the state it shipped in its HTML, and the small share that truly needs rendering can be rendered lean on a shared pool rather than a farm. Build the tiers, cache the decision at the template level, and reserve the browser for the residue. You will run a fraction of the infrastructure and break a fraction as often.

FAQ

How do I find the hidden API a JavaScript site uses?

Open your browser's developer tools, go to the Network panel, and filter to XHR/fetch requests. Reload the page and watch which request returns a response containing the data you want, usually as JSON. That is the endpoint the page itself calls to render. Note its URL, method, headers, and query parameters, then replicate the call in a plain HTTP client. If the endpoint is protected by a token the page generates, you may need to render once to capture that token and then reuse it across many direct API calls.

Do I ever actually need a headless browser for scraping?

Yes, but far less often than the default reflex suggests. You need one when the data is produced by requests you cannot easily replay, is assembled across multiple interactions, or is gated behind clicks and scrolls. The goal is not to eliminate rendering but to minimise it: route everything you can to direct API calls and embedded-state parsing, and reserve a lean, shared rendering service for the genuine residue rather than rendering every page by default.

Top comments (0)