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.

The short version
  • 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.
Two paths to a rendered page. The build path owns a control plane: a pool leases isolated browser contexts up to a fixed ceiling, and everything past that ceiling waits. The buy path deletes the control plane and issues one request.

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.

final/src
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.

bash
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

typescript
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 density has a blast radius

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

typescript
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.

One lease, end to end. Acquire reserves capacity before any context exists. Release either recycles the session or hands it straight to a waiting caller, so the bounded set of contexts serves far more tasks than its own size.

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:

bash
npm run pool
output
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

typescript
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:

output
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.

Where the 100 nodes come from. Usable RAM sets a ceiling of 114 sessions per node; the operational cap of 100 sets the real number. Both figures matter, and only one of them is what you run.

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

typescript
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.

Crawlbase Crawling API

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.

Start Building

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.

Self-serve · No sales call required · Enterprise crawl volumes available