all posts

Building a Headless Browser Automation Farm on MicroVMs

Ajay Kumar··9 min read

A browser automation farm is a machine that runs Chromium — roughly 30 million lines of C++ that will happily execute whatever the internet sends it — thousands of times a day, on pages you did not write and cannot vet. Scraping, crawling, screenshotting, PDF rendering, end-to-end test suites, and now AI agents that drive a browser to click model-chosen links: every one of these points a full rendering engine at hostile-by-default content and asks it to run. The farm's whole job is to execute untrusted remote code at scale. So the only question that matters for the architecture is the containment one: when — not if — a single Chromium session gets crashed, wedged, or outright pwned, what does it take down with it? This post is about making the answer 'itself, and nothing else,' by giving every browser session its own disposable microVM.

The farm's threat model: every session is renders-arbitrary-web-content

Most untrusted-code posts start with 'what if a user submits something malicious?' A browser farm inverts that: malicious input isn't the edge case, it's the load. Every page you load fetches and runs untrusted JavaScript, parses untrusted HTML and CSS, decodes untrusted images and fonts, and increasingly runs untrusted WASM — across a rendering engine large enough that renderer-RCE-plus-sandbox-escape is a recurring, patched-nearly-weekly class of in-the-wild exploit. Chrome's own site isolation and renderer sandbox are genuinely excellent defense-in-depth, but they still sit on the host kernel like any other process. Now run that surface a few thousand times over on a fleet, and enumerate what a single bad session can do if the wall between sessions is thin:

  • Crash the neighbors — a page that balloons memory, spawns a fork bomb of headless tabs, or trips an OOM killer takes down not just its own session but every other session sharing that host's kernel and memory. The noisy neighbor is a rendering engine, and the page is actively trying to be noisy.
  • Escape to the host — a renderer exploit chained to a browser-sandbox escape runs code on the box hosting the browser. If that box also hosts a hundred other sessions, one pwned Chromium now owns all of them, their cookies, and their cached credentials.
  • SSRF your network — a browser's reason to exist is making network requests. A hijacked or prompt-injected session can turn 'fetch this URL' into 'fetch http://169.254.169.254/ and read the cloud metadata credentials,' or scan your VPC and hit the internal admin service that trusts anything on the private plane.
  • Exfiltrate quietly — no escape required. A malicious page can beacon out whatever it scraped, POST secrets it found, or abuse network access to phone home, all while looking like a perfectly normal render.
  • Leave a mess for the next job — a download the page asked you to save, a poisoned cache, a cookie jar, resident malware in a long-lived profile. On a reused browser, that state contaminates the next session that lands on it.
The defining constraint of a browser farm isn't throughput — it's containment and blast radius. You're running one of the largest attack surfaces in computing, thousands of times over, on content chosen by scrapers, users, or a language model. The farm lives or dies on how strong the boundary between sessions is and how fast you can stand up a clean one.

Why not one big Chromium pool behind a shared kernel

The reflexive farm design is a pool of headless Chromium processes — or a pool of containers each running one — behind a queue, recycling browser contexts between jobs. It's cheap and it's fast, and for a farm that only ever renders your own trusted pages it's fine. For arbitrary web content it's the wrong boundary, and the reason is architectural, not a tuning problem. Containers are Linux namespaces and cgroups around a process that still shares the host's single kernel with every other container on the machine. That shared kernel is a large, privileged attack surface — and it's exactly what a renderer exploit probes to escape. Recycling a browser context between jobs is worse still: cookies, cache, service workers, and IndexedDB have a long history of leaking across contexts, so 'reuse the browser, clear the context' quietly trades away the isolation you thought you had.

You can harden the pool a long way — seccomp, user namespaces, a read-only rootfs, dropped capabilities, gVisor or Kata in front, one browser context per job with aggressive teardown. Those are real and you should use them. But they're mitigations layered on a boundary that was never designed to contain adversarial code: you're narrowing a shared kernel's surface, not removing the sharing. For content this hostile, the right boundary is a separate kernel — a hardware virtualization boundary the CPU enforces — so a compromised renderer is trapped inside a guest that can't see the host's kernel at all. That's a microVM, and it's why AWS built Firecracker to run Lambda between tenants rather than trusting containers.

Recycling a browser context resets the cookies. Deleting a microVM resets the kernel. When Chromium just ran a page you didn't write, you want the second kind of reset.

The pattern: one browser, one microVM, one disposable session

The clean farm architecture is a single browser per microVM, a fresh VM created for each session, and the VM destroyed when the session ends. The control loop is the same whether the trigger is a scrape job off a queue, a screenshot request, a test run, or an agent task:

  1. Provision a fresh, hardware-isolated microVM on the browser template — a clean guest kernel, a clean Chromium, no state from any prior job.
  2. Deliver the work: write the target URL, the Playwright/Puppeteer script, or the crawl frontier into the guest filesystem. The session reads it there.
  3. Drive the browser with a hard timeout: launch headless Chromium, navigate, click, scrape, screenshot — exec returns stdout, stderr, and an exit code, so you can tell a clean finish from a crash from a timeout.
  4. Capture the artifact: read the screenshot, PDF, scraped JSON, or test report back off the filesystem. That result is the only thing that escapes the VM.
  5. Destroy: delete the VM. Cookies, cache, downloads, resident malware, and a wedged renderer all die with it — there is no cleanup step because there is nothing left to clean.

Ephemerality is the whole point, and it's what makes teardown a state reset for free. Because the VM is disposable, you never carry cookies, localStorage, a half-written download, or a coin-miner from one job into the next — there is no next job on that VM. A crashed or pwned session's blast radius is exactly one VM you were about to throw away. Always attach a TTL so an abandoned or runaway browsing VM reaps itself instead of lingering and holding a Chromium hostage.

The two things people forget to contain are downloads and in-page JavaScript. A headless browser will run every script a page ships and write any file a page hands it — that's the browser doing its job, not a misconfiguration. Inside a disposable microVM that's fine: the malware runs, the file lands on a filesystem you're about to delete. The danger is running the browser anywhere that script or download can touch a filesystem, secret, or network you actually care about.

A farm worker in Python

Here's one session end to end with the Python SDK: create a browser-template microVM, write a Playwright script into the guest, drive headless Chromium against a URL you don't necessarily trust, read the screenshot back out as bytes, and tear the VM down. Set PANDASTACK_API_KEY in the environment first. The `metadata` tag attributes the VM to a job for auditing, and `ttl_seconds` is a backstop so a hung render kills its own VM.

import json
from pandastack import PandaStack

ps = PandaStack()  # reads PANDASTACK_API_KEY, base url https://api.pandastack.ai

SCRIPT = """
import asyncio
from playwright.async_api import async_playwright

async def main():
    async with async_playwright() as p:
        browser = await p.chromium.launch()  # headless by default
        page = await browser.new_page()
        await page.goto("__URL__", wait_until="networkidle")
        await page.screenshot(path="/workspace/shot.png", full_page=True)
        print(await page.title())
        await browser.close()

asyncio.run(main())
"""

def render(url: str, job_id: str) -> bytes:
    # 1. Fresh, hardware-isolated microVM per session. Every create restores a
    #    baked snapshot (~179ms p50), so a VM-per-session is cheap. The browser
    #    template is baked at 4 GiB / 4 vCPU (Chromium is hungry).
    sb = ps.sandboxes.create(
        template="browser",
        ttl_seconds=120,                      # hard cap: a hung render self-destructs
        metadata={"job": job_id, "kind": "screenshot"},
    )
    try:
        # 2. Deliver the work as plain files. This session's URL never touches
        #    another session's VM.
        sb.filesystem.write("/workspace/run.py", SCRIPT.replace("__URL__", url))

        # 3. Drive Chromium with a hard timeout. A page that wedges the renderer
        #    is killed; exit_code tells a clean render from a crash from a timeout.
        result = sb.exec("python3 /workspace/run.py", timeout_seconds=60)
        if result.exit_code != 0:
            raise RuntimeError(result.stderr[-2000:])

        # 4. Pull the artifact back out. This is the ONLY thing that escapes the
        #    VM — any drive-by exploit or download dies with the guest.
        return sb.filesystem.read("/workspace/shot.png")
    finally:
        # 5. Destroy. Cookies, cache, downloads, resident malware — all gone.
        sb.delete()

png = render("https://example.com", job_id="job-42")
with open("shot.png", "wb") as f:
    f.write(png)

The two safety rails aren't decoration. The `timeout_seconds` on `exec` is a circuit breaker for a render that hangs on a page that never goes idle; the `ttl_seconds` on create is a backstop so a VM you forget to reap reaps itself. Untrusted content gets a wall, a clock, and a hard cap — then it gets thrown away. If you'd rather run the browser as a persistent process and hit it over the DevTools protocol, the same shape works: launch headless Chromium detached and drive it. Here's a bare-metal version of launching it from a plain exec.

# Inside the guest, launched via sb.exec(...). The browser template already
# ships headless Chromium + the automation stack; nothing to install.

# Launch headless Chromium with remote debugging on a guest-local port.
chromium \
  --headless=new \
  --no-sandbox \
  --disable-dev-shm-usage \
  --remote-debugging-port=9222 \
  --remote-debugging-address=127.0.0.1 &

# Or render a URL straight to PDF in one shot — the classic farm workload.
chromium --headless=new --no-sandbox \
  --print-to-pdf=/workspace/out.pdf \
  --no-pdf-header-footer \
  "https://example.com"

# --no-sandbox is safe *here* precisely because the microVM is the sandbox:
# Chromium's own sandbox wants privileges the guest doesn't grant, and the
# hardware VM boundary is the real wall. Never do this outside a disposable VM.

Egress control: so a hijacked renderer can't SSRF your network

Isolation stops a session from reading its neighbors. Egress control stops it from phoning home or turning inward. You need both, because they're different problems. A browser exists to make network requests, so a perfectly isolated session can still POST scraped data to an attacker's server, or — worse — pivot inward: the database that trusts anything on the VPC, the admin service on a private port, the cloud metadata endpoint at 169.254.169.254 that hands IAM credentials to anyone who asks. A prompt-injected agent or a malicious page is a gift-wrapped SSRF primitive if you let the renderer reach the network freely.

The microVM boundary makes this natural to enforce, because every sandbox gets its own Linux network namespace with its own veth pair, TAP device, and NAT rules — PandaStack pre-allocates 16,384 /30 subnets per agent, one per sandbox, so a session's network is genuinely its own segment, not a shared bridge. That per-VM network is your enforcement point:

  • Block the internal ranges and the metadata IP first: deny RFC1918 (10/8, 172.16/12, 192.168/16) and link-local 169.254.0.0/16 — which covers the cloud metadata endpoint — so a renderer can never reach your database, control plane, or IAM credentials. This is the single most important rule.
  • Allowlist destinations for targeted crawls: if a farm only ever scrapes a known set of sites, prefer an explicit allowlist over trying to enumerate everything evil. Allowlists fail closed; blocklists lose.
  • Inject no host credentials into the guest: the browser VM should hold nothing worth exfiltrating. No cloud keys, no service tokens, no shared secrets in the environment.
  • Rate-limit and cap egress: bound how much a single session can send, so a hijacked renderer can't become an exfiltration firehose or a DDoS amplifier on your bill.
  • Keep the timeout and TTL: an egress-heavy page that's also slow is a resource-exhaustion attack. The hard clock is part of your network defense, not only your CPU defense.

Snapshot a warm browser, fork to fan out sessions fast

Here's where a microVM farm does something a container pool can't easily match. Getting a browser to a useful starting state is expensive: Chromium startup, profile load, an authenticated login, a target page rendered and idle. When you need that same warm state across many parallel sessions — fan-out scraping from one logged-in entry point, screenshotting a page across hundreds of viewports, best-of-N agent attempts on the same page, or parallelizing a test suite from a warmed session — you don't want to repeat that setup N times.

Instead, get one sandbox to the warm browser-and-page state, snapshot it, then fork. A fork is a copy-on-write clone: guest memory is shared via MAP_PRIVATE and the rootfs via XFS reflink, so each child diverges only on the pages it actually writes — no gigabyte copy. A same-host fork lands in roughly 400–750ms; a cross-host fork, when you're spreading load across agents, is about 1.2–3.5s. Every child inherits the live Chromium process, the loaded page, and the authenticated session, then browses independently — and because each fork is its own microVM, a malicious page in one branch can't touch its siblings. That's how a farm fans out from one warm state to a hundred isolated sessions in under a second each.

Why a VM-per-session is cheap enough to be the default

The old objection to 'a VM per browser session' was cost: nobody waits ten seconds for a VM to boot before a page can load, and a farm doing that would fall over. That objection is dead, because a per-session microVM is a snapshot restore, not a cold boot. A cold boot does real work — the kernel initializes, userspace comes up, Chromium loads. A restore skips all of it: Firecracker maps a frozen, already-booted VM's memory and device state back in and resumes mid-instruction, closer to unpausing than to starting a computer.

On PandaStack that restore-based create runs at a p50 of 179ms and a p99 around 203ms (the restore-and-resume core is roughly 49ms), versus a genuine cold boot of about 3 seconds that happens exactly once per template and is then baked into a snapshot and amortized away. The browser template is baked at 4 GiB of RAM and 4 vCPUs — Chromium is hungry, and that's the fixed guest size a restore brings up. Memory is copy-on-write and the rootfs is a reflink clone, so the Nth identical browser VM shares the baked pages until it writes. Add same-host forks at 400–750ms and 16,384 /30 subnets per agent, and a clean, egress-locked, hardware-isolated browser per session stops being a luxury: it's the cheap default that a shared Chromium pool can only pretend to be.

VM-per-session vs. shared browser pool vs. context recycling

There are three ways to shape a browser farm, and they trade off along the same axes. Worth laying them side by side rather than picking one dogmatically:

  • Isolation strength — VM-per-session: strongest; a clean guest kernel per session, a pwned renderer trapped behind hardware virtualization. Shared browser pool (one context per job): weak; a shared host kernel and a browser process reused across jobs, so a renderer escape or a context leak reaches everyone. Context recycling in one long-lived browser: weakest; cookies, cache, and service workers have a long history of bleeding across contexts.
  • Blast radius of a crash/pwn — VM-per-session: one disposable VM. Shared pool: the host and every job on it. Context recycling: the whole browser and every session it has ever served.
  • State between jobs — VM-per-session: none by construction; teardown is a full reset. Shared pool: whatever the context-clear missed. Context recycling: leaks unless you're heroically careful, which is the failure mode.
  • SSRF / egress control — VM-per-session: per-session network namespace, default-deny inward, natural to enforce. Shared pool: one bridge for everyone, coarse and easy to get wrong. Context recycling: no network boundary between jobs at all.
  • Cost floor — VM-per-session: ~179ms restore per session (400–750ms if forked from warm), near-zero idle. Shared pool: near-zero per job, paid in blast radius. Context recycling: cheapest per job, paid in contamination and containment you don't actually have.

In practice a real farm blends them by trust: VM-per-session as the default for anything rendering content you didn't write, forked-from-warm when you need to fan out from an authenticated state, and a shared pool reserved only for your own trusted first-party pages where the adversary argument evaporates. Match the shape to the threat model.

Where a microVM farm pays off

The pattern shows up anywhere a browser meets content you didn't write, at volume:

  • Scraping and crawling at scale — fan out across many isolated browsers, each with its own egress controls and clean profile; a hostile target page can't poison the others or the orchestrator.
  • Screenshot and PDF rendering services — turn a URL into an image or PDF on demand all day long, every render in a fresh, expendable browser that dies with whatever the page shipped.
  • Automated end-to-end testing — a clean, identical browser per test run with zero leftover state, and a warmed logged-in session forked out to parallelize the suite.
  • AI agents driving a browser — every model-chosen navigation, click, form-fill, and download contained to a throwaway VM with locked-down egress, because the navigation target is adversarial by assumption.
  • Ad-tech and security detonation — open suspicious URLs, ad creatives, and phishing pages in instrumented browsers to observe behavior, then discard the VM with whatever it picked up.

The common thread is that Chromium is doing exactly what Chromium does — executing remote code on remote content — and you've simply moved the blast radius. A renderer exploit, a malicious download, a wedged tab, a prompt-injected agent that decides to exfiltrate: the worst outcome is a deleted microVM you were going to throw away anyway. One giant attack surface, one disposable hardware boundary, one session at a time — that's a browser automation farm that scales without scaling its blast radius alongside it.

Frequently asked questions

Why isolate each browser session in its own microVM instead of a shared Chromium pool?

A browser executes arbitrary remote JavaScript and decodes untrusted HTML, images, fonts, and WASM on every page — it's one of the largest attack surfaces you can run, and renderer-plus-sandbox-escape exploits are a recurring in-the-wild class. A shared pool runs those sessions behind one host kernel and often reuses the browser process across jobs, so an escape or a context leak reaches every session on the box. A microVM gives each browser its own guest kernel under hardware virtualization (KVM), so a compromised renderer is trapped behind the hypervisor boundary, and destroying the VM is a full state reset. With sub-second creation (~179ms p50 on PandaStack), a disposable VM per session is practical rather than a luxury.

Doesn't a VM per browser session make the farm too slow or expensive?

No, because a per-session microVM is a snapshot restore, not a cold boot. PandaStack creates a browser sandbox in about 179ms at p50 (203ms p99) by restoring a baked snapshot on demand, with memory copy-on-write and the rootfs a reflink clone, so the Nth identical browser VM doesn't copy gigabytes. The genuine ~3-second cold boot happens once per template and is amortized away, and idle cost is near zero. To fan out from a warm, authenticated browser, a same-host fork lands in 400–750ms (cross-host 1.2–3.5s). Those numbers make a clean, isolated browser per session the cheap default rather than an expensive luxury.

How do you stop a hijacked browser session from reaching your internal network (SSRF)?

Enforce egress at the session's own network namespace. Block the private RFC1918 ranges (10/8, 172.16/12, 192.168/16) and link-local 169.254.0.0/16 — which covers the cloud metadata endpoint at 169.254.169.254 — so a renderer can never reach your database, control plane, or IAM credentials, and inject no host credentials into the guest. For targeted crawls, prefer an explicit allowlist of destinations over a blocklist, since allowlists fail closed, and rate-limit egress so a hijacked session can't become an exfiltration firehose. On PandaStack each sandbox already runs in its own network namespace with its own NAT (16,384 /30 subnets per agent), which is the natural enforcement point.

How do I run many parallel browser sessions from one warm, logged-in state?

Get one sandbox to the state you want — Chromium launched, page loaded, session authenticated — then snapshot and fork it. A fork is a copy-on-write clone: guest memory is shared via MAP_PRIVATE and the rootfs via XFS reflink, so each child diverges only on what it writes. A same-host fork lands in roughly 400–750ms (cross-host is about 1.2–3.5s). Every child inherits the live browser and loaded page, then browses independently, and because each fork is its own microVM, a hostile page in one branch can't reach its siblings — ideal for fan-out scraping, multi-viewport screenshots, best-of-N agent attempts, or parallelizing a test suite from a warmed session.

Run code in a microVM in one API call.

49ms p50 cold start. Fork, snapshot, and scale to zero.

Start free
Written by Ajay Kumar, Founder, PandaStack.