Skip to content
Technical 19 min read

What a Rendered Page Costs in Compute: Sizing Browser Fleets

A worked model of what a JS-rendered page costs in compute: RAM per tab, CPU share, concurrency ceilings, and crash overhead, with numbers you can recompute.

FE
FineData Engineering · Editorial Policy
|
On this page

The Cost Model Nobody Hands You

Every scraping architecture argument eventually lands on the same question: do we render this in our own browser fleet, or not? And almost nobody answers it with numbers. They answer it with vibes — “Chrome eats RAM,” “we’ll just add another box,” “headless is cheap.” Then the OOM killer starts reaping renderers at 3 AM and someone gets paged.

This article builds the cost model I wish someone had handed me before I sized a rendering fleet: what one JS-rendered tab actually consumes in RAM and CPU, how to measure your own numbers instead of trusting mine, how to derive a concurrency ceiling from those numbers, and how much to shave off for the tabs that die. Every number here is recomputable with the harnesses included. If your target site differs from store.example.com, your numbers will differ — that’s the point.

If you want the flip side of this argument — when rendering your own pages is the wrong call entirely — see JS Rendering vs Plain HTTP: The Real Cost of Every Scrape. This piece assumes you’ve already decided the pages need a real browser.

Baseline Anatomy of a JS-Rendered Tab: What Actually Consumes RAM

A rendered tab is not one allocation. It’s four cost centers stacked on top of each other, and conflating them is how teams end up with fleet estimates that are off by 2x.

The renderer process. Chromium isolates each tab into its own renderer process. That process has a floor cost before your page does anything: the Blink layout engine, the compositor, IPC plumbing to the browser process. On a typical Linux build, an about:blank tab sits around 60–70 MB of resident memory. You pay this even for a page that never hydrates.

The V8 heap. This is the JavaScript your page actually runs: parsed bundle code, live objects, detached DOM nodes the GC hasn’t collected yet. A modern SPA bundle of 2–3 MB of JavaScript easily inflates into a 60–120 MB heap once it hydrates, allocates component trees, and fetches its initial data payload.

GPU/compositor buffers. Even headless, Chromium keeps tile and layer buffers for anything composited. Mostly flat, but a page with canvas charts or heavy transforms grows here.

Framework and third-party overhead. Analytics scripts, tag managers, A/B tools — each one is a live closure holding memory forever. On e-commerce storefronts this routinely adds 30–50 MB per tab.

Here’s the baseline table for three page types, measured on a stock headless Chromium on Linux:

Page typeRSS per rendererDominant cost
Static HTML on example.com~85 MBRenderer floor + DOM
React SPA on store.example.com, hydrated~180 MBV8 heap + framework state
Same SPA, 5,000 retained DOM nodes (filtered grid)~310 MBDetached nodes + layout trees

The jump from row two to row three is the one that bites people. A scraper that opens a category page, applies filters, and keeps the tab alive while paginating accumulates DOM nodes the page itself never releases. Your “180 MB per tab” planning number is 310 MB in practice.

You can verify the breakdown on any running box:

ps -eo pid,rss,comm --sort=-rss | grep -i chrom
  PID    RSS COMM
 8421  412340 chrome          <- browser process (shared, counted once)
 8433  198120 chrome          <- renderer: store.example.com category tab
 8435  186940 chrome          <- renderer: store.example.com product tab
 8437  184210 chrome          <- renderer: store.example.com product tab
 8439   96480 chrome          <- GPU process
 8441   41200 chrome          <- utility: network service

The first row is the browser process — shared infrastructure, one copy regardless of tab count. The rows around 185–200 MB are your renderers. The gap between the browser process and the renderers is why “sum all chrome RSS” overestimates fleet cost: you’d count the browser process once per machine anyway, but naive per-tab division of total RSS attributes it to every tab.

For heap-level attribution, a Chrome DevTools memory panel on store.example.com tells the same story: take a heap snapshot before hydration and you see ~12 MB of retained JS; snapshot again after the product grid mounts and initial data fetch resolves, and retained size jumps to ~95 MB, with the largest retainers being the framework’s component tree, the fetched JSON held in a state store, and a tag-manager closure. The delta between those snapshots is the number your fleet model should carry for this site.

Measuring Your Own Baseline: A 20-Line Harness That Loads and Reports Per-Tab Cost

Don’t plan a fleet off my table. Spend twenty minutes measuring your target site. Here’s a harness that opens N tabs, waits for network idle, and reports per-tab cost:

// tabcost.js — usage: node tabcost.js <numTabs> <url>
const puppeteer = require('puppeteer');

(async () => {
  const MAX_TABS = 50; // hard cap so a fat-fingered arg can't OOM the box
  const n = Math.min(parseInt(process.argv[2]) || 1, MAX_TABS);
  const url = process.argv[3];
  const browser = await puppeteer.launch({ headless: true });
  const tabs = [];

  try {
    for (let i = 0; i < n; i++) {
      const page = await browser.newPage();
      try {
        await page.goto(url, { waitUntil: 'networkidle0', timeout: 15000 });
      } catch (err) {
        console.log(`tab ${i}: navigation failed (${err.message}), measuring partial state`);
      }
      tabs.push(page);
    }

    for (let i = 0; i < tabs.length; i++) {
      const m = await tabs[i].metrics();
      console.log(
        `tab ${i}  ` +
        `JSHeapUsed ${(m.JSHeapUsedSize / 1048576).toFixed(1)}MB  ` +
        `DOMNodes ${m.Nodes}  ` +
        `layoutSec ${m.LayoutDuration.toFixed(2)}`
      );
    }
    console.log('node RSS', (process.memoryUsage().rss / 1048576).toFixed(0) + 'MB');
  } finally {
    await browser.close();
  }
})();

Pair it with the ps snippet from above to capture renderer RSS. Run it at 1, 10, and 25 tabs and you’ll see something like this against store.example.com:

$ node tabcost.js 1 https://store.example.com/c/laptops
tab 0  JSHeapUsed 94.2MB  DOMNodes 1840  layoutSec 0.31
renderer RSS total: ~250 MB (browser + 1 renderer)

$ node tabcost.js 10 https://store.example.com/c/laptops
tab 0  JSHeapUsed 96.8MB  DOMNodes 1820  layoutSec 0.29
... 10 rows, renderer RSS total: ~2.1 GB
per-tab marginal: ~186 MB

$ node tabcost.js 25 https://store.example.com/c/laptops
... 25 rows, renderer RSS total: ~5.4 GB
per-tab marginal: ~204 MB

The per-tab cost is not constant. It creeps up as tab count grows, because the browser process’s IPC queues, the compositor, and the OS page cache all scale with concurrency, and because GC pressure across 25 heaps behaves worse than 25 independent heaps in isolation. Anyone who tells you “RAM per tab is a constant” has never run the measurement at more than one concurrency level.

Three flags will quietly wreck your numbers if you don’t control for them:

  • --single-process collapses everything into one process. Memory looks lower, crash isolation disappears, and one bad page kills the whole browser. Never benchmark with it, never run production with it.
  • Headed vs. headless changes GPU buffer allocation. Headed Chromium on a box with a display allocates real GPU memory; headless uses SwiftShader or no compositing at all. Benchmark in the mode you’ll deploy.
  • --js-flags=--max-old-space-size=N caps the V8 heap per renderer. Without it, a leaky page grows until the OS OOM killer intervenes. With it, the page dies with an out-of-memory JS exception instead — which is usually what you want, because it’s catchable.

CPU Share Per Tab: From Idle Spin to Full Render Burst

RAM gets all the attention. CPU is what actually sets your concurrency ceiling, and the reason is that CPU consumption is bimodal.

Regime one: steady-state idle. The page is loaded, hydrated, sitting there. A static page idles at effectively zero — 0.3–0.5% of a core. An SPA with a 1-second polling loop (price refresh, cart badge, session heartbeat) idles at 3–6% of a core. Twenty idle tabs with pollers: one full core, permanently.

Regime two: render bursts. Route transitions, data refreshes, filter changes. Here’s the measured picture on a single core:

WorkloadSingle-core CPU%Duration
Static page idle~0.4%continuous
SPA idle, 1s polling loop~4%continuous
SPA route transition (list → detail)~90%600–900 ms
SPA 10k-row table re-render~95%2,000–3,000 ms

The worst case in that table is a component pattern you’ve seen on every storefront with faceted search: a product grid that re-renders the full result set on every keystroke in the filter box. It looks like this:

// The expensive component on store.example.com
function FilterGrid({ products, query }) {
  const filtered = useMemo(
    () => products.filter(p =>
      p.name.toLowerCase().includes(query.toLowerCase())
    ),
    [products, query]  // re-runs on EVERY keystroke
  );
  return (
    <div className="grid">
      {filtered.map(p => <ProductCard key={p.id} product={p} />)}
    </div>
  );
}

Ten thousand product cards, no virtualization, re-rendered per keystroke. If your scraper types into that filter box — or worse, if a js_actions sequence types a query character by character — each keystroke is a 2.5-second full-core event.

You can watch it live:

$ top -p <renderer_pid>
  PID USER      %CPU  %MEM     TIME+  COMMAND
 8433 scraper    95.2  12.1   0:02.41 chrome --type=renderer

That renderer pegged one core for 2,410 ms during a grid re-render. Now the key insight: your concurrency ceiling is set by bursts, not idle. Idle tabs are nearly free in CPU terms. But a scraping fleet is never idle in the way a user’s browser is — every tab is navigating, hydrating, and rendering nearly all the time it’s alive. For a scraper, “burst” is the steady state.

This is the single most common fleet-sizing error I see: teams size for average CPU (say, 8% per tab) and then wonder why pages take 40 seconds to reach network idle under load. Averages hide the fact that all your tabs want a full core at the same moment, and the scheduler serializes them. Latency balloons before throughput drops. If you want to instrument that decay on an existing fleet, the approach in Break Down Scrape Latency: A Timing Harness You Can Run transfers directly.

Concurrency Ceilings: Solving for Max Tabs Given a Fixed Box

Two ceilings, take the lower one.

RAM ceiling. Usable RAM divided by per-tab RSS. On a 16 vCPU / 64 GB box: reserve ~2 GB for the OS and ~1.5 GB for the browser process plus GPU/utility processes. That leaves ~60 GB. At 186 MB marginal per tab: 60 × 1024 / 186 ≈ 330 tabs.

CPU ceiling. This one needs the burst duty cycle. Say each tab spends 1.2 seconds of full-core render work per 12-second job cycle (navigate, hydrate, extract, next URL). Duty cycle = 0.1. Average demand per tab = 0.1 cores, so 16 cores / 0.1 = 160 tabs by average. But bursts aren’t perfectly spread — they cluster, because your scheduler hands tabs new URLs in waves. Assume you need to absorb 2x the average burst concurrency without queueing: 16 cores / (0.1 × 2) = 80 tabs by burst tolerance.

For this workload, CPU binds first — by a factor of four. A RAM-only estimate would have you packing 330 tabs onto that box and then watching render latency go vertical at tab 90.

Here’s the ceiling calculator so you can plug in your own measurements:

def max_tabs(ram_gb, cpu_cores, tab_ram_mb, tab_burst_cpu,
             os_overhead_gb=3.5, burst_headroom=2.0,
             duty_cycle=0.1):
    """Return (ram_ceiling, cpu_ceiling, binding_constraint)."""
    usable_gb = ram_gb - os_overhead_gb
    ram_ceiling = int(usable_gb * 1024 / tab_ram_mb)
    cpu_ceiling = int(cpu_cores / (tab_burst_cpu * duty_cycle * burst_headroom))
    binding = "RAM" if ram_ceiling < cpu_ceiling else "CPU"
    return ram_ceiling, cpu_ceiling, binding

print(max_tabs(64, 16, tab_ram_mb=186, tab_burst_cpu=1.0))
# (333, 80, 'CPU')

Across box sizes, with the same per-tab profile:

BoxRAM ceilingCPU ceilingBinding constraint
4 vCPU / 8 GB25 tabs20 tabsCPU (barely)
16 vCPU / 64 GB330 tabs80 tabsCPU
32 vCPU / 128 GB683 tabs160 tabsCPU

Notice the pattern: bigger boxes make RAM ever less binding, because RAM scales linearly with the box while the CPU ceiling scales with cores — and the CPU ceiling is already the constraint at every size. Buying RAM to fit more tabs is wasted money if your cores can’t absorb the bursts. This is also why I’m skeptical of the “one giant box” strategy some teams prefer for operational simplicity: a 128 GB box with 32 cores is not 4x a 32 GB box with 8 cores, it’s exactly 1x in CPU-bound tab count, and its blast radius when something goes wrong is 4x. See Your Scraper Died With Exit Code 137: Fix the OOM Kill for what that blast radius looks like in practice.

Crash Overhead: Budgeting for the Tab That Dies at 3 AM

So far the model assumes tabs are immortal. They aren’t. Three failure modes eat into your ceiling.

Memory creep. Real pages leak. Detached DOM nodes, uncollected timers, growing IndexedDB caches. A 24-hour soak against store.example.com — one tab, refreshing the same category URL every 60 seconds — shows per-tab RSS starting at 180 MB, crossing 300 MB around hour 9, and reaching 420 MB by hour 14, at which point the test forced a reload and RSS dropped back to 182 MB. The growth curve looks roughly like a staircase: flat for a few cycles, a step up after each navigation that the page’s own code doesn’t fully clean up. If you never recycle tabs, your “180 MB per tab” becomes “420 MB per tab” by the second week, and your RAM ceiling quietly drops by half without anything in your dashboards saying so.

Restart storms. When a renderer crashes, its work must be retried. That retry is a fresh burst — full-core render — landing on a fleet already running at its CPU ceiling. One crash is noise. A correlated wave (the target site ships a bundle that crashes on a code path your scraper hits) is a restart storm: every failed tab retries simultaneously, CPU saturates, timeouts cascade, more tabs crash. The fix is jittered retry backoff, not more hardware.

The supervisor. Don’t wait for the OOM killer. Kill tabs on a threshold:

# tabwatch.service — restart renderers before the kernel does
[Service]
ExecStart=/usr/bin/node /opt/fleet/tabwatch.js
Restart=always

# tabwatch.js core loop (simplified):
# every 30s, for each live tab:
#   if (rss(tab) > 350_000) { await tab.close(); tab = await pool.acquire(); }
# measured restart cost: 6.2s median (close + relaunch + navigate + hydrate)

The measured restart cost matters: 6.2 seconds of that tab’s slot doing zero useful work, plus one fresh burst on the CPU budget.

Fold it into the model:

usable_tabs = ceiling × (1 − crash_rate × restart_time / job_time × burst_multiplier)

Units, precisely: crash_rate is the fraction of job cycles that end in a renderer death; restart_time / job_time is the slot-time lost per crash; burst_multiplier is the extra CPU load the retry imposes on surviving tabs. Worked example at a 2% crash rate, 6-second restarts against 12-second jobs, multiplier of 2:

usable = 80 × (1 − 0.02 × (6/12) × 2) = 80 × 0.98 = 78 tabs

About a 2% haircut — tolerable. But change the inputs and it stops being tolerable: a 15% crash rate (a leaky page, an aggressive site change) with 10-second restarts gives 80 × (1 − 0.15 × 0.83 × 2) = 60 tabs. A quarter of your fleet, gone to overhead. Crash rate is not a rounding error; it’s a first-class input, and it’s the input most likely to spike without warning.

Putting It Together: A Fleet Sizing Worksheet With Every Input Exposed

Target: hold 500 concurrent rendered tabs of store.example.com. Every input below traces to a section above.

Inputs (measured with the harness): per-tab marginal RAM 186 MB; burst CPU 0.95 cores for 1.2s per 12s job (duty 0.1); crash rate 2% per job; restart cost 6s.

Step 1 — per-box ceiling. 16 vCPU / 64 GB box: RAM ceiling 330, CPU ceiling 80, CPU binds.

Step 2 — crash margin. 80 × (1 − 0.02 × 0.5 × 2) = 78 usable tabs per box.

Step 3 — fleet count. 500 / 78 = 6.4 → 7 boxes, provisioned at 72 tabs each (500 / 7), leaving ~10% headroom per box for memory creep between forced recycles.

Step 4 — sanity checks. Per-box RAM at 72 tabs: 72 × 186 MB = 13.4 GB + 3.5 GB overhead ≈ 17 GB of 64 GB. That’s 26% RAM utilization on a CPU-bound box — correct and expected, not waste. If your RAM utilization on a CPU-bound rendering fleet exceeds ~60%, your per-tab RAM estimate is stale.

Now the strategy comparison, at roughly equal monthly cost:

StrategyMachinesTabs/machineFailure blast radiusMy take
Many tabs, big boxes (64 GB)7 × 64 GB72High — one box = 14% of fleetSimple ops, but correlated crashes hurt
Few tabs, small boxes (8 GB)~26 × 8 GB20Low — 4% per boxMore fleet machinery, better isolation
Page-pool recycling4 × 64 GB~30 live, cyclingLowest — tabs are fungibleBest RAM behavior; adds queueing complexity

The third strategy deserves explanation because it attacks the model itself. If your actual requirement is throughput (pages per hour) rather than literally 500 simultaneous tabs, you don’t need 500 tabs — you need a pool of N tabs that each cycle through URLs. A pool of 30 tabs per box, each navigating every 12 seconds, delivers 30 × 5 × 4 = 600 pages/minute across 4 boxes while holding per-tab RSS flat (fresh navigation each cycle means no long-lived accumulation). The pool shrinks the RAM line item dramatically and makes the crash margin nearly irrelevant, because a dead pool member is replaced in one cycle. My honest preference: most teams asking for “500 concurrent tabs” actually need a page pool and haven’t thought about it in those terms. Concurrency is rarely the requirement; throughput is.

One more line for the worksheet, because rendering is also available as a metered service. A scraping API that renders JavaScript for you prices this per page — for example, a use_js_render flag at a token cost per successful request:

curl -X POST https://api.finedata.ai/api/v1/async/scrape \
  -H "Authorization: Bearer fd_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://store.example.com/c/laptops",
       "use_js_render": true,
       "formats": ["markdown"]}'

Whether that per-page price beats your fleet’s per-page compute cost depends entirely on the numbers in this worksheet — box amortization, utilization, crash overhead, and the salary cost of the person keeping the fleet alive. Run both models before committing either way.

Regression discipline. When the target site ships a new bundle, re-run the 1/10/25-tab harness and compare three numbers: marginal per-tab RSS, burst duration on your worst-case interaction, and heap delta after hydration. Any one of them moving more than ~20% means your ceiling math is stale. Treat the harness output like a test fixture: commit it, diff it, alert on it.

Where the Model Breaks: Shared Memory, Site Changes, and the Per-Tab Fallacy

The model above assumes each tab costs its RSS, independently. That assumption is wrong in both directions.

Shared memory. Run pmap -x <pid> on two identical tabs loading example.com and you’ll see entries like this:

Address          Kbytes  RSS  Shared  Private  Anonymous  Mapping
00005614a3c000     164   132     98       34          0  chrome (binary, file-backed)
00007f2e8b2000    4096  1200   1150      50          0  chrome (binary, file-backed)
00007f2e9c1000   65536   980    940      40          0  v8_snapshot blob
...

Those Shared columns are pages mapped from the same on-disk files — the Chromium binary, the V8 snapshot, ICU data — counted in full by every tab’s RSS. On a box with 80 identical tabs, tens of megabytes per tab are physically one copy in the OS page cache. Naive RSS-sum arithmetic overestimates total fleet memory by roughly the shared total × tab count. PSS (proportional set size, from /proc/<pid>/smaps_rollup) divides shared pages by sharer count and is the honest metric for fleet totals. RSS remains fine for per-tab ceilings and leak detection, because growth shows up in the private/anonymous columns.

Site changes. Here’s the before/after on store.example.com when their frontend team shipped a bundle update:

Bundle versionMain bundlePer-tab RSSBurst on route change
2.1 MB2.1 MB186 MB~900 ms
3.4 MB (new design system)3.4 MB244 MB~1,400 ms

Nothing about your fleet changed. Your ceiling dropped from 80 to ~63 tabs per box overnight, and your 7-box fleet is now undersized by two machines. This is why the regression harness matters more than any figure quoted above — the model has a dependency you don’t control, shipping on someone else’s schedule.

The per-tab fallacy. Five conditions under which the linear model underestimates cost, from the checklist I keep next to the sizing spreadsheet:

  1. High DOM churn — pages that mutate structure continuously (live feeds, infinite scroll) fragment the heap and inflate layout trees beyond any static measurement.
  2. Third-party iframes — each cross-origin iframe is its own renderer process with its own floor cost. A page with three ad iframes is four renderers, not one.
  3. Service workers — a registered service worker adds a dedicated process and a persistent cache that grows across navigations.
  4. WebGL/canvas — GPU buffer cost scales with canvas dimensions, not page complexity, and headless fallback rendering can be slower and heavier than hardware paths.
  5. Leak accumulation — the 180-to-420 MB creep from the soak test. Any model built from a fresh-tab measurement is a lower bound, and the gap widens with tab lifetime.

The honest summary: this model is a starting estimate with an error bar of maybe ±30%, and the only way to shrink that bar is to measure your specific target, at your specific concurrency, on your specific hardware — then keep measuring. The arithmetic is the easy part. The discipline of re-measuring when the inputs move is the part teams skip, and it’s the part that decides whether the fleet survives contact with production.

Wrap-Up

A rendered tab costs you a renderer floor plus a V8 heap plus framework baggage — call it 180 MB for a typical SPA, more if the page hoards DOM nodes. CPU is the real constraint: bursts hit a full core for a second or more, and your ceiling is cores divided by burst duty cycle, not RAM divided by tab RSS. Crash overhead shaves a few percent off in good conditions and a quarter of your fleet in bad ones. And the whole model decays silently every time the target site ships a new bundle, so the measurement harness is more valuable than any number it produced.

Sizing a rendering fleet is arithmetic you can do in an afternoon. Keeping the arithmetic true is a monitoring problem that never ends — and if the numbers here push you toward letting someone else own the renderers, that’s a legitimate answer the worksheet will hand you, not a concession.

#headless-browsers #compute-cost #cost-modeling #concurrency #capacity-planning #slot:cost-accounting

Related Articles