Headless Browser Concurrency Limits and Pool Sizing
Multiply a single browser's resource cost by your throughput target to size your pool correctly.

Concurrency limits in headless browser deployments come directly from what a single Chromium process costs in RAM and CPU. Once you know those costs, pool sizing stops being guesswork and starts being arithmetic.
Most teams find this out the hard way. Browser automation starts as a scripting task: a Puppeteer script that grabs prices off a competitor's site once an hour, a Playwright test suite that runs on every pull request. Then someone asks for the same workflow to run across twenty regions, ten thousand times an hour, and the whole thing falls over. CPU pegs at 100% for no obvious reason. Memory balloons until the OOM killer starts picking off processes one by one. Timeouts show up that look flaky, the kind where a rerun fixes it, except reruns stop fixing it around week three.
A headless Chromium instance is a full browser carrying its own rendering pipeline, its own networking stack, and its own V8 isolate for running JavaScript. Every one of those pieces has a resource cost, and concurrency limits are just what happens when you add those costs up across however many instances you're running at once. This piece walks through those costs one layer at a time, RAM first, then CPU, then the formulas and architecture choices that follow from them, so that by the end, the number you land on for pool size is one you can defend with math rather than one you arrived at by watching a server catch fire and dialing things back.
What a single headless Chromium instance actually costs in RAM
A cold-started headless Chromium instance eats somewhere between 50 MB and 150 MB of RAM before it has loaded a single page. That's before any real work happens: shader compilation, V8 isolate setup, and general process bootstrapping all add latency on top of the memory hit, so startup is expensive on both axes at once.
Once it's actually doing something, each instance typically settles into a 100 MB to 300 MB range. Run ten of them at once to scrape ten thousand pages and you've committed 1 to 3 GB of memory before you've thought about anything else running on that box: your app layer, your OS, your monitoring agent.
Here's the part that trips people up, though: that memory use is not a flat line. An instance that opens at a tidy 200 MB can climb past 1 GB after enough page loads, and it doesn't happen because of one bad page. Most pages release their memory cleanly when the tab closes. But a subset don't: video-heavy pages, JS frameworks with sloppy cleanup, pages with enormous DOM trees. Those leave residue behind that never gets reclaimed, and the residue compounds. Run a fleet for eight hours and you might see resident memory sitting at 1.5 GB per instance. Let it run a full day and OOM kills start becoming a regular occurrence rather than a rare event.
There's a multiplier hiding in here too. Under site isolation policies, a single tab can spawn several renderer processes, not just one. So a system running 1,000 concurrent pages isn't running 1,000 processes; it might be running several thousand. That distinction matters enormously when someone's doing infrastructure cost math based on "instance count" instead of "process count," because the bill scales with the latter.
Put it together and the mental model should flip: in most large browser fleets, you run out of memory before you run out of compute. Memory-bound, not CPU-bound.
CPU costs and why a 20%-utilized cluster can still fall over
CPU guidance for headless Chromium at scale usually lands somewhere in this range: a bare minimum of 2 to 4 CPU cores per 10 concurrent instances, a more comfortable ratio of 1 core per 2 to 3 instances, and for JavaScript-heavy sites, closer to 1 core per instance. That last tier surprises people; a page loaded with client-side rendering and heavy event listeners chews through CPU fast, and the usage doesn't taper off on its own.
Here's the genuinely counterintuitive part. A cluster can show 20% average CPU utilization on a dashboard and still be actively falling apart. Average utilization hides the spikes and the overhead: memory fragmentation, process-scheduling contention, and constant context switching as the OS juggles hundreds of Chrome-related processes. None of that shows up cleanly in a CPU percentage graph, but all of it degrades stability.
So the engineer who provisions purely off a CPU dashboard is going to under-provision, guaranteed. Per-process memory growth over time and total process count are the signals that actually tell you something, since an aggregate utilization number stays busy averaging away the exact spikes that are about to take the node down. Both axes, RAM and CPU, need to be understood together; that's what makes the sizing formulas in the next section something you can derive, rather than something you copy off a blog post and hope fits your workload.
Two RAM-based formulas for deriving a starting pool size
The first formula is a production-tested rule of thumb, and it's simple enough to do on a napkin: take your available RAM, subtract 2 GB to cover the OS and your application layer, then divide the remainder by 500 MB per Chrome instance. On a 16 GB server, that gives a theoretical ceiling around 28 instances. In practice, you'd want to cap it lower, somewhere around 20 to 25, so there's headroom to absorb traffic spikes without immediately tipping into swap. The sweet spot for a dedicated server running Chrome workloads tends to be 8 to 16 virtual cores paired with 16 to 32 GB of RAM, which comfortably handles 15 to 25 instances depending on how heavy the pages are.
The second formula is more conservative and better suited to unpredictable workloads where you don't have a clean read on page complexity yet: concurrent pages roughly equal available RAM minus 1 GB, divided by 300 MB. Pair it with Chrome flags that cap memory growth directly, things like --js-flags="--max-old-space-size=512" to bound the V8 heap, and --renderer-process-limit=4 to stop a single page from spawning a small army of renderer processes on its own.
These two formulas bracket a reasonable range rather than pointing to one correct answer; which one fits depends on whether your pages are simple and predictable or messy and JS-heavy, and whether memory growth stays flat over time or accumulates the way we described in the RAM section. As a reality check: on a 2 GB VPS, 10 to 20 concurrent screenshot jobs is roughly where things cap out before the OOM killer becomes a regular visitor to your logs. That number lines up with both formulas, which is reassuring; it means the math isn't just theoretical.
What neither formula tells you is how much concurrency your actual workload needs in the first place. Sizing hardware around a number you haven't validated against real demand is how teams end up renting far more infrastructure than they'll ever use.
Using Little's Law to find out how much concurrency the workload actually needs
Little's Law, borrowed straight from queueing theory, gives a clean way to answer that: concurrency equals pages per second multiplied by seconds per page. Applied to a browser fleet, it tells you exactly how many sessions you need running at once to hit a given throughput target.
Here's a worked example. Say a JavaScript-heavy page takes about 5 seconds to render to domcontentloaded. At that rate, a fleet of 10,000 concurrent sessions could theoretically push through 172.8 million pages a day. That's an enormous number, and it's almost never the number that matters. If the actual business requirement is 1 million pages a day, the concurrency needed to hit that is roughly 58 sessions. Put another way, a request for 10,000 concurrent sessions is about 173 times larger than what the workload actually demands.
That gap matters because infrastructure gets provisioned against the big scary number, not the real one. A hundred-node fleet with 3.2 TB of combined RAM sustaining 10,000 concurrent sessions is built for a peak that most teams will never, ever hit; it's sized for a worst case someone imagined once in a planning meeting, not for measured demand. The better order of operations is to calculate the concurrency your actual throughput target requires first, using Little's Law, and only then size the hardware around that number with the RAM formulas from the last section. Backwards from that, and you're paying for headroom nobody uses.
Knowing the right concurrency number solves half the problem. The other half is the architecture managing those sessions, because how you structure sessions can make that number go a lot further, or waste most of it.
Instance-per-session vs. browser context: the architectural choice that multiplies or divides your pool size
The instance-per-session model, historically the default in tools like Selenium, spawns a full browser process for every single task. Each of those processes pays the entire fixed cost on its own: its own GPU process, its own network service, its own V8 isolate spinning up from scratch. Run 100 concurrent tasks this way and you've duplicated that fixed overhead 100 separate times, with no sharing of the underlying resources at all.
Playwright's browser context model takes a different approach. A context gives you full session isolation, separate cookies, separate local storage, separate auth state, all without launching a new OS process. Spinning up a new context takes single-digit milliseconds, a fraction of what a full process launch requires. One browser process can comfortably host dozens or even hundreds of isolated contexts at the same time. Teams that have moved their test suites to this parallel execution model have reported cutting CI infrastructure costs by 40 to 50%, while holding execution times steady or improving them.
The limits are worth knowing before you bet the architecture on this model. If the underlying browser process crashes, every context inside it goes down together, so the blast radius is wider than with instance-per-session, where a crash takes out exactly one task. Cram too many contexts into a single process and that process's own memory footprint balloons, meaning the isolation gain has a ceiling you'll eventually hit. And every context sharing one browser process also shares the same browser fingerprint and user agent; anti-bot systems that fingerprint at the process level can pick up on that pattern. For workloads where detection risk is high, separate instances might genuinely be worth the extra cost, fingerprint diversity being the thing you're buying.
As a general rule, context-based architecture is the sensible default for test automation and most scraping work. Instance-per-session earns its keep specifically when fingerprint diversity or per-task crash isolation is a hard requirement, not a nice-to-have.
The warm pool pattern and how to manage browser lifecycle in production
Launching a fresh browser process for every incoming request sounds simple until you look at what that launch actually costs: loading the binary and spinning up the full suite of supporting processes. That overhead alone is enough to saturate CPU, completely independent of how heavy the page rendering itself is. You can end up CPU-bound by processes that haven't even started doing real work yet.
The warm pool pattern sidesteps this. Instead of launching on demand, you keep a set of pre-warmed browser instances sitting ready. A worker picks one up from a queue, finishes its task, and hands the instance back for the next job. Startup latency gets removed from the hot path entirely; nobody's waiting on a cold boot mid-request.
Running the pool well means tracking instance health and restarting instances before memory growth turns into a real problem rather than after. The three signals worth watching at the pool level are active instance count, queue depth, and failure rate. One particularly useful diagnostic: Chrome process count per pod. In a healthy, well-sized Puppeteer service, a pod typically runs somewhere around 2 to 3 Chrome processes; drifting noticeably above that baseline is an early sign something's accumulating that shouldn't be.
For multi-server setups, a common pattern has each server publish its current load and available capacity through a heartbeat, with a gateway routing new requests toward servers that actually have room rather than blindly round-robining traffic into nodes that are already underwater.
The whole point of the warm pool is that pool size becomes a stable, planned number you set on purpose, not one that quietly drifts upward because launch overhead triggered retries, and those retries spawned more instances, and now nobody remembers why the pool is twice the size it was supposed to be.
Queue depth, backpressure, and what happens when demand exceeds pool capacity
Without explicit backpressure, demand exceeding pool capacity tends to fail in one of two ways. Either requests silently vanish with no error message, which is miserable to debug because nothing in the logs tells you where they went, or the queue grows without bound until the node runs out of memory and takes every active job down with it on its way out.
A well-designed pool configuration governs three things: the number of active sessions allowed at once (matching the pool size you derived from the formulas earlier), how many requests can wait once every concurrent slot is full, and how long a single session can run before it is forcibly ended so one stalled page doesn't hold a slot hostage indefinitely.
There are also memory health check thresholds that reject new incoming sessions once the node crosses them, though sessions already running are left alone to finish. Setting these conservatively — 80% is a commonly documented target — gives the system a chance to reject new work before the OS OOM killer decides to intervene and kills the whole container mid-session instead.
Queue depth is a buffer. If it's consistently sitting full, that's telling you the pool is undersized; the answer is to raise CONCURRENT, not to keep stretching QUEUED further and further until it's functioning as a second, slower queue instead of a fix.
Zombie processes, memory leaks, and OOM management in long-running fleets
Here's a scenario worth sitting with: the Node process running your automation crashes mid-render, maybe an unhandled promise rejection, maybe an OOM kill of its own. The Chrome child process it had launched doesn't die with it. It just sits there, resident in memory, invisible to whatever's managing your pool, quietly doing nothing useful except taking up RAM. This happens in Docker containers, on bare metal servers, inside Lambda environments; it's not something specific to any one deployment style, it's just what happens when a parent process dies without cleaning up after its children.
A lot of what gets called a "memory leak" in these fleets is really this, plus one more wrinkle: Chrome deliberately holds onto some resources after a tab closes, anticipating the next navigation might reuse them. That's normally a reasonable optimization. In a high-throughput environment processing thousands of pages an hour, those anticipations stack up faster than they get released, and the aggregate effect looks exactly like a leak even though no single line of code is doing anything wrong.
The fix is a stack of smaller habits. Watch Chrome process count per pod as a primary health metric, since unexpected growth flags orphaned processes before memory pressure turns critical. Enforce restart policies based on render count and uptime rather than waiting for memory readings to cross some threshold; proactive recycling beats reactive OOM handling every time. And inside containers, give Chrome real shared memory with --shm-size=2g rather than reaching for --disable-dev-shm-usage, which reroutes those writes to /tmp and quietly tanks performance.
Tie this back to sizing: zombie processes shrink your effective pool below whatever number you provisioned. A pool sized for 20 instances might actually be running 14 usable ones if nobody's watching for zombie accumulation and clearing it out.
Horizontal vs. vertical scaling, and a tiered architecture for mixed workloads
Vertical scaling, cramming more concurrent instances onto one big machine, works fine until it doesn't. Past a certain point, adding more instances to a single node creates contention for CPU scheduling and memory bandwidth that actually erodes per-instance performance; you're paying for more capacity and getting less of it back. A distributed setup of several smaller nodes frequently beats one large machine for Chrome workloads specifically, and dedicated, non-virtualized servers have a real edge here too, since Chrome gets direct access to CPU and memory without a hypervisor adding its own scheduling overhead on top.
The practical version of horizontal scaling is adding more containers behind a load balancer rather than cranking CONCURRENT higher and higher on a single instance. Every Chrome process on a node is competing with every other Chrome process on that same node for the same finite resources; spreading them across nodes removes that competition entirely instead of trying to manage around it.
There's one more lever worth pulling before reaching for more hardware at all: not every request in a mixed workload actually needs a full headless browser. Plenty of pages are static enough that a fast HTTP client can fetch and parse them without ever touching Chrome. Routing those requests away from the headless pool and reserving it for pages that genuinely require JavaScript rendering effectively multiplies the useful capacity of a pool that's already correctly sized, no additional servers required. It's the cheapest scaling move on this entire list, and it's also the one that's easiest to forget, mostly because it doesn't feel like "real" infrastructure work. It's just routing logic, and it's frequently the difference that matters most.

