all posts

How many browsers can one machine actually run?

Ajay Kumar··8 min read

The question comes up whenever a scraper, test suite or agent workload starts to grow: how many headless browsers can I run at once? People usually guess based on CPU cores and are wrong by a factor of several, because the binding constraint is memory and browsers use much more of it than expected.

The actual numbers

A headless Chromium is not one process. It's a process tree: a browser process, a GPU process, a network service, a storage service, and one renderer per tab — with more if the page uses cross-origin iframes, since site isolation gives those their own processes.

Chromium process tree, one simple page      ~250-350 MB
... a heavy SPA (React, large bundle)       ~400-700 MB
... a page with several cross-origin iframes ~600 MB-1 GB
... video, canvas, or WebGL                  1 GB+

Rough capacity, leaving 2 GB for the OS and your own code:
   4 GB machine   →   4-6 browsers
   8 GB machine   →  12-16 browsers
  16 GB machine   →  25-35 browsers

CPU rarely binds first. Browsers spend most of their time waiting on network, and a busy renderer uses a fraction of a core on average. You'll run out of memory well before cores, which is why sizing by core count produces machines that thrash.

Memory usage grows over a session. A browser that has loaded forty pages holds more than one that has loaded two — caches, detached DOM nodes, and leaked listeners in the site's own JavaScript. Capacity measured on a fresh browser will not hold an hour in.

Contexts are cheap, and they are not isolation

The standard advice for density is to use one browser with many contexts rather than many browsers. It's correct about cost and frequently misunderstood about safety.

// Cheap: contexts share the browser process tree
const browser = await chromium.launch();
const a = await browser.newContext();   // separate cookies, storage, cache
const b = await browser.newContext();
// ~20-50 MB each rather than ~300 MB

A context isolates cookies, local storage, cache and permissions. That's genuine and it's enough for parallel tests of your own application, or for handling multiple accounts on a site you trust.

It is not a security boundary. Contexts share a browser process, shared memory, and the same operating system user. A renderer exploit — a real and regularly patched category of bug — escapes into a process that has access to every context's data. If you're automating untrusted sites, or letting an AI agent browse wherever it decides to, contexts are the wrong tool for the isolation part of the job.

The /dev/shm problem

Worth its own section because it produces a failure that looks like a bug in your code and isn't.

Chromium uses shared memory heavily for moving rendered frames between processes. Docker defaults `/dev/shm` to 64 MB, which is small enough that a moderately complex page exhausts it. The browser then crashes with a message that has nothing to do with shared memory.

# Either give it real shared memory
docker run --shm-size=2gb my-scraper

# Or disable its use — costs performance, and is the usual advice
# purely because the first option is often not available
chromium --disable-dev-shm-usage

In a VM this doesn't arise, because `/dev/shm` is sized from actual memory rather than a container default. It's a small example of a general pattern: a lot of browser automation folklore is workarounds for container-specific constraints, carried forward into environments that never had them.

What happens past the limit

The failure mode is bad specifically because it doesn't look like resource exhaustion.

  1. Memory pressure rises and the kernel starts reclaiming aggressively.
  2. Swap engages if it exists, and page loads become erratically slow — sometimes fine, sometimes ten seconds.
  3. The OOM killer picks a process. Frequently a renderer, sometimes the browser process itself.
  4. Your automation reports 'target closed', 'page crashed', or a timeout on an element that was about to appear.
  5. You conclude the tests are flaky, add retries, and the retries increase concurrency further.

Every step of that is plausible from inside the automation, which is why teams spend weeks on 'flaky browser tests' that were a capacity problem the whole time. The tell is that the failure rate correlates with concurrency rather than with any particular test.

Scaling past one machine

Three shapes, with different properties.

A browser pool behind a queue

N browsers, a work queue, jobs check one out and return it. Efficient, and the standard answer. The catch is that browsers accumulate state and memory across jobs, so you need a recycling policy — restart each browser every N jobs or when its resident memory crosses a threshold, or the pool degrades over hours in a way that's hard to attribute.

A fresh machine per job

Maximum isolation, no state accumulation, no recycling policy. Traditionally too slow, because booting a machine per job costs seconds to minutes.

Snapshot restore changes that arithmetic. If a machine with a browser already running restores in well under a second, per-job isolation stops being a luxury. Each job gets a separate kernel — so one job's memory leak, crash or filled disk is invisible to every other — and the job ends by deleting the machine rather than trying to return it to a clean state.

import { Sandbox } from "@pandastack/sdk";

// Warm state captured once: browser running, logged in, page loaded
async function runJob(url) {
  const s = await Sandbox.fork(BROWSER_SNAPSHOT);
  try {
    return await s.exec(`node scrape.js ${url}`);
  } finally {
    await s.delete();   // no recycling policy, no leaked state
  }
}

Pools of short-lived machines

In practice a middle ground works well: a machine handles a bounded number of jobs — ten, fifty, whatever keeps memory flat — then is destroyed and replaced. You get most of the efficiency of a pool with a hard ceiling on state accumulation, and no policy more complicated than a counter.

Measure your own number

The figures above are starting points. Your pages are heavier or lighter than average, and the only number that matters is the one your workload produces.

# Total resident memory across the browser process tree, over a real run
while true; do
  ps -eo rss,comm | grep -i chrom | awk '{s+=$1} END {print s/1024 " MB"}'
  sleep 5
done

# Then: does it plateau, or climb? A climbing line is a leak, and it means
# your safe concurrency depends on how long the process has been alive.
# Recycle before the climb reaches your ceiling.

Run that against your actual pages for an hour at your intended concurrency. If memory is flat, your limit is the plateau. If it climbs, your limit is a function of time and you need a recycling policy — and knowing which of those two you're in is most of the work.

Frequently asked questions

How many headless browsers can one server run?

Memory decides, not CPU. A Chromium process tree uses roughly 250 to 350 MB for a simple page, 400 to 700 MB for a heavy single-page application, and over a gigabyte for pages using video, canvas or WebGL. Leaving a couple of gigabytes for the operating system, that means about 4 to 6 browsers on a 4 GB machine, 12 to 16 on 8 GB, and 25 to 35 on 16 GB. Browsers spend most of their time waiting on network, so you will exhaust memory long before cores.

Are browser contexts a security boundary?

No. A context isolates cookies, local storage, cache and permissions, which is genuine isolation and sufficient for running parallel tests of your own application or handling several accounts on a site you trust. But contexts share a browser process, shared memory and the same operating system user, so a renderer exploit — a real and regularly patched class of bug — reaches every context's data. For automating untrusted sites, or letting an agent browse wherever it decides, you need process or machine separation, not contexts.

Why does Chromium crash in Docker with a shared memory error?

Docker defaults /dev/shm to 64 MB and Chromium uses shared memory heavily to move rendered frames between processes, so a moderately complex page exhausts it and the browser crashes with a message that rarely mentions shared memory. Fix it with --shm-size=2gb on the container, or fall back to --disable-dev-shm-usage, which costs performance and is common advice mainly because the first option is often unavailable. Inside a VM the problem does not arise, since /dev/shm is sized from real memory.

Why do browser tests get flaky as I increase concurrency?

Because you have crossed the memory limit and the failure does not announce itself as resource exhaustion. Memory pressure rises, the kernel reclaims aggressively, page loads become erratically slow, and eventually the OOM killer terminates a renderer or the browser process. From inside the automation that appears as 'target closed', 'page crashed', or a timeout on an element that was about to render — all of which look like flaky tests. The tell is that failure rate correlates with concurrency rather than with any particular test.

Should I pool browsers or start a fresh one per job?

Pools are more efficient but need a recycling policy, because browsers accumulate memory and state across jobs and degrade over hours in ways that are hard to attribute. A fresh machine per job removes that entirely but was traditionally too slow to consider. Snapshot restore changes the arithmetic: if a machine with a browser already running restores in well under a second, per-job isolation becomes practical. A good middle ground is a pool of machines each handling a bounded number of jobs before being destroyed and replaced.

Keep reading

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.