Someone asks the infrastructure question eventually: what does it take to support 10,000 concurrent browser sessions? It sounds like a capacity question with a hardware answer. It is really a units question, and the units are wrong.
Ten thousand concurrent sessions is not a workload. It is a provisioning decision. The workload is pages per day, and the two are related by one line of arithmetic that decides the whole build-versus-buy argument before any hardware is priced.
This post builds the thing that governs a browser fleet, in TypeScript and Playwright: a session pool with a hard concurrency ceiling, leasing, and recycling. Then it prices the fleet that 10,000 sessions actually requires, converts that number into the workload it serves, and compares it against managed rendering at the same throughput rather than at the same headline figure.
- Concurrency equals throughput multiplied by service time. At five seconds per rendered page, 10,000 sessions is 2,000 pages per second, or 172.8 million per day.
- Most teams asking for 10,000 sessions need about 1% of that. A million pages a day needs a concurrency of roughly 58.
- The fleet that sustains 10,000 sessions is about 100 nodes and 3.2 TB of RAM, sized for peak and idle the rest of the time.
- A blocked page costs a self-run fleet the same RAM-seconds as a successful one. On the Crawling API, failed requests are not billed at all.
- Every context on a node lives inside one browser process. One crash takes all of them.
10,000 sessions is a provisioning number, not a workload
Little's Law is the conversion. The number of operations in flight equals the completion rate multiplied by how long each one takes. For a browser fleet that reads: concurrency equals pages per second multiplied by seconds per page.
A JavaScript-heavy page rendered to domcontentloaded takes a few seconds. Call it five. Then 10,000 concurrent sessions is not a demand figure at all, it is this:
| Workload | Pages per second | Concurrency needed at 5s per page |
|---|---|---|
| 1 million pages/day | 11.6 | 58 |
| 10 million pages/day | 115.7 | 579 |
| 172.8 million pages/day | 2,000 | 10,000 |
Ten thousand concurrent sessions is the provisioning for 172.8 million pages a day. If your actual requirement is a million pages a day, the concurrency that serves it is 58, and the request for 10,000 sessions is about 173 times larger than the workload behind it.
That is worth settling before pricing anything, because the entire build-versus-buy comparison changes depending on which number is real. So the honest version of the question is not "can we run 10,000 sessions" but "what is our pages per day, what is our latency per page, and what concurrency does that pair imply?"
The reference implementation
The companion project in ScraperHub/scaling-a-headless-browser-fleet-to-10000-concurrent-sessions is organised as three runnable pieces, each answering a different part of the question.
session.ts one browser process, many isolated contexts pool.ts the control plane: ceiling, leasing, recycling capacity.ts per-session assumptions to node count and RAM crawlbase.ts the managed path, one HTTP request index.ts the runner for all three modes
It needs Node.js 18 or newer and Playwright's Chromium. Only the managed path needs a token.
git clone https://github.com/ScraperHub/scaling-a-headless-browser-fleet-to-10000-concurrent-sessions.git cd scaling-a-headless-browser-fleet-to-10000-concurrent-sessions/final npm install npx playwright install chromium cp .env.example .env
A session is a context, not a browser
The first architectural decision is the unit of concurrency. Here a session is a Playwright browser context, not a browser process. A context carries its own cookies, storage, and execution state, and costs a fraction of what a fresh Chromium process costs. That is what makes high density possible at all.
Source: final/src/session.ts
async open(url: string, timeoutMs: number): Promise<OpenResult> { const page = await this.context.newPage(); try { const response = await page.goto(url, { timeout: timeoutMs, waitUntil: 'domcontentloaded' }); const title = await page.title(); return { status: response ? response.status() : 0, title }; } finally { await page.close(); } }
The page is deliberately short-lived: open, navigate, read one signal, close. Short page lifetimes keep state from accumulating, so a single context serves many sequential tasks while the browser lifecycle stays in the factory.
The factory launches one Chromium and creates every context inside it. That is what makes a context cheap, and it also means a browser crash takes every session on that node with it. At an operational ceiling of 100 sessions per node, one crash is 100 lost sessions, and the pool's healthy() check discovers it per session, after the fact, on the next acquire or release. Density and blast radius are the same dial.
The control plane is where the guarantee lives
The pool decides whether a caller gets an existing session, a new one, or a wait. Those three outcomes are the whole of fleet stability.
Source: final/src/pool.ts
if (this.live < this.maxConcurrency) { // Reserve the slot synchronously, before the await. this.live += 1; this.created += 1; this.inUse += 1; this.peakInUse = Math.max(this.peakInUse, this.inUse); try { return await this.factory.create(); } catch (error) { this.live -= 1; this.inUse -= 1; throw error; } } // Fleet is full. Queue and wait for a release. return new Promise<Session>((resolve) => { this.waiters.push(resolve); });
One detail carries the entire guarantee: the slot is reserved before awaiting factory.create(). Because await yields, several callers would otherwise all read live < maxConcurrency while the first context was still being created. A ceiling of five could briefly become twenty. Reserving synchronously and rolling back on failure is what makes the limit real rather than advisory.
The release path closes the loop. A session that has aged out or failed its health check is recycled, and when callers are already queued the pool creates a replacement immediately, so capacity does not quietly shrink every time a session is retired.
What it looks like under contention
Run more tasks than the ceiling allows:
npm run pool
tasks=20 ok=20 elapsed=541ms throughput=37.0/s pool: maxConcurrency=5 peakInUse=5 created=5 recycled=0 peak utilization=100%
The second line is the one that matters. Twenty tasks finished, five contexts existed, and peakInUse never passed the ceiling. The other fifteen tasks were served by leasing and releasing the same five sessions. Set MAX_SESSION_AGE_MS=0 and the recycled counter climbs instead, exercising the retirement path that keeps long-running fleets from accumulating stale state.
Note what this run does not establish. It is five contexts against example.com on one machine, so it validates the allocation logic and nothing about scale. The throughput figure, 37 requests per second, is a property of a trivial page and a local browser, not a forecast.
Pricing the fleet
The capacity model turns per-session assumptions into a node count and a RAM bill. It is deliberately small, because the point is the arithmetic, not the tool.
Source: final/src/capacity.ts
const usableRamMb = (input.nodeRamGb - input.nodeReserveGb) * 1024; const ramBoundSessionsPerNode = Math.max( 1, Math.floor(usableRamMb / input.ramPerSessionMb) ); const effectiveSessionsPerNode = Math.min( input.configuredSessionsPerNode, ramBoundSessionsPerNode ); const nodes = Math.ceil(input.targetSessions / effectiveSessionsPerNode);
With 250 MB per session, 32 GB nodes holding back 4 GB each, and an operational ceiling of 100 sessions per node:
target sessions: 10000 RAM-bound sessions/node: 114 effective sessions/node: 100 nodes required: 100 total fleet RAM: ~3200 GB
Memory alone would allow 114 sessions per node. The model takes 100 because that is the number you would actually run, and the gap between the two is the difference between a spec sheet and a production fleet. The result is roughly 100 nodes and 3.2 TB of RAM, before autoscaling, browser image maintenance, crash recovery, monitoring, deploys, and the on-call rota that keeps all of it alive.
Two costs the node count does not show
The 100-node figure is the sticker price. Two things move the real one, and both favour whichever side of the decision has better utilisation.
The fleet is sized for peak and paid for continuously
Capacity is provisioned against the busiest hour and rented for all of them. A fleet whose peak is three times its average runs near a third utilisation, so each useful page carries roughly three times the hardware cost the spec sheet implies. Autoscaling narrows that gap and does not close it: browsers need warm-up, and scaling on a metric that moves in seconds means either lag or headroom.
Failed pages cost the same as successful ones
A page that returns a challenge, a timeout, or a soft block consumed exactly the same context, memory, and wall-clock as a page that returned data. On a self-run fleet you pay for it identically. On the Crawling API you do not: requests that fail are not billed, so a retry against a flaky target changes your latency but not your invoice.
That difference scales with how hostile the targets are. On a well-behaved corpus it is a rounding error. On sites with real anti-bot defenses, where a meaningful share of attempts end in a challenge, it is a large fraction of the bill that only one of the two models charges you for.
The managed path
The buy path deletes the control plane. There is no pool, no warm-up, no recycling, and nothing to capacity-plan, because the browser runs on the other side of an API call.
Source: final/src/crawlbase.ts
export async function crawlbaseRender( url: string, token: string, timeoutMs: number ): Promise<RenderResult> { const endpoint = `https://api.crawlbase.com/?token=${token}&url=${encodeURIComponent(url)}`; const response = await fetch(endpoint, { signal: controller.signal }); const body = await response.text(); const cbStatus = Number(response.headers.get('cb_status') ?? response.status); return { cbStatus, bytes: body.length, ms: Date.now() - started }; }
The JavaScript token is what makes this a browser rather than a fetch: it drives a real rendering engine on the other end. Read cb_status rather than the HTTP status, because that is the field describing what happened to the target, as opposed to what happened to your API call.
Concurrency here is a plan setting rather than a fleet, and it is raised on request. Which is exactly why comparing it to a session count is the wrong move: the useful comparison is at matched throughput. Take your pages per day, divide by 86,400, multiply by your seconds per page, and compare the concurrency that falls out against the fleet that same number would require.
Rendering concurrency without the fleet: a real browser executes on our side, behind rotating residential IPs, and returns one clean response. Failed requests are not billed, so hostile targets cost you latency instead of invoice. Start free with 1,000 requests, no card.
Making the decision
Once the workload is expressed in pages per day, the choice stops being ideological.
| Build the fleet when | Buy the rendering when |
|---|---|
| Browser execution is itself the product | Rendering feeds a product that is something else |
| You need control of the full browser lifecycle | You need pages, not browsers |
| Platform engineers are already on staff and on call | That headcount is better spent downstream |
| Utilisation is high and predictable | Demand is spiky, so peak-sized capacity idles |
| Targets are friendly and failure rates are low | Targets fight back and failures are a real share of attempts |
The last two rows are the ones teams underweight. They are also the two the capacity model cannot see, because both are properties of the workload rather than the hardware.
Conclusion
Ten thousand concurrent sessions is an answer to a question most teams have not asked precisely. Converted into a workload it is 172.8 million pages a day; converted back, a million pages a day needs a concurrency of 58. Getting those two numbers straight settles more of the argument than any benchmark.
The control plane itself is not the hard part, and the reference implementation shows why: a bounded ceiling, a lease, a recycle path, and one carefully placed increment before an await. That is a few hundred lines and it is finished.
What does not finish is the operating: 100 nodes sized for a peak they spend most of the day below, one browser process per node holding a hundred sessions hostage to a single crash, and a bill that charges the same for a blocked page as a delivered one. The build is a weekend. The fleet is a rota.
Frequently asked questions
Is a session a browser or a browser context?
A browser context. It carries isolated cookies, storage, and execution state at a fraction of the cost of a separate Chromium process, which is what makes a hundred sessions per node feasible at all. The tradeoff is that every context on a node shares one browser process, so a crash takes all of them together.
Why does reserving the concurrency slot before the await matter so much?
Because await yields execution. If the counter is incremented after the context is created, every caller that arrives during that gap reads the old value and passes the ceiling check, so a configured limit of five can briefly create twenty contexts. Reserving the slot synchronously and rolling it back on failure is the difference between a limit and a suggestion.
Why distinguish RAM-bound from effective sessions per node?
One is what memory permits and the other is what you would actually run. At 250 MB per session on a 32 GB node with 4 GB held back, memory allows 114; the operational ceiling of 100 is the number that sizes the fleet. Running at the memory limit leaves nothing for a traffic spike or a leaking context.
What assumptions produce the 100-node estimate?
250 MB per session, 32 GB per node, 4 GB reserved per node, and an operational cap of 100 sessions per node, against a target of 10,000 sessions. All four are configurable in capacity.ts, and all four are worth replacing with your own measurements before quoting the result, since per-session memory in particular varies enormously with what the pages actually load.
How do I compare managed concurrency against a session count?
You do not, because they are different units. Convert both to throughput first: divide your pages per day by 86,400 to get pages per second, then multiply by your measured seconds per page to get the concurrency each side needs. Compare at matched throughput, and size the managed plan against that figure rather than against the headline session count.
Does the demo's 37 requests per second predict fleet throughput?
No. That number came from five contexts hitting example.com on one machine, so it measures the allocation logic against a trivial page, not rendering under real conditions. A JavaScript-heavy page takes seconds rather than milliseconds, and that latency is the input that decides how much concurrency any target throughput requires.
Crawl any site at scale, without fighting infrastructure.
Crawlbase handles proxies, fingerprints, and CAPTCHAs so your team ships data pipelines instead of maintaining crawl plumbing. Up to 5,000 requests free, no card required.
