A service that takes screenshots on request has to decide what one request owns: a whole Chromium, a browser context inside a shared Chromium, or just a page inside a shared context. I measured the three on one machine, then measured what each choice leaks when it goes wrong.

Short version of what I found: sharing a browser saved about half a second per job when jobs ran one at a time, and about 240 MiB at concurrency 2. The bigger differences were isolation and cleanup. A forgotten context.close() cost about 105 MiB per job and took the browser from 230 MiB to 6.6 GB in 60 jobs.

Everything ran on 2026-11-11: Linux 6.8, 6 cores, Node v24.22.2, Playwright 1.63.0 with the bundled chrome-headless-shell (Chrome for Testing 153.0.8010.12). The machine also runs other services, so load average was 2 to 3 and absolute times are slow. Compare the rows with each other, not with your laptop. Only the headless shell was tested, only on Linux, only against a local fixture server.

The test

Each job opens a page with 12 SVG images (served by a local server with a 40 ms delay each), 200 paragraphs of text, waits for load, takes a full-page 1280 px wide screenshot and closes what it owns. 24 jobs per run. The modes:

  • launch: every job starts its own browser and closes it.
  • context: one browser, every job creates and closes its own context.
  • page: one browser and one shared context, every job opens and closes a page.

Concurrency is the number of jobs running at the same time. The first file measures memory by summing the resident set size (RSS) of every process below the Node process, read from /proc. RSS counts pages shared between processes more than once, so it overstates real use. It is fine for comparing rows.

// file: lib.cjs const fs = require('fs'); // Sum RSS (MiB) and count of all processes below this Node process, from /proc. exports.treeRss = function treeRss() { const all = [], kids = {}; for (const d of fs.readdirSync('/proc').filter(x => /^\d+$/.test(x))) { try { const st = fs.readFileSync(`/proc/${d}/stat`, 'utf8'); const f = st.slice(st.lastIndexOf(')') + 2).split(' '); all.push([+d, +f[1], +f[21] * 4]); // pid, ppid, rss in KiB (pages * 4) } catch {} } for (const [p, pp] of all) (kids[pp] ||= []).push(p); const rss = Object.fromEntries(all.map(([p, , r]) => [p, r])); let sum = 0, n = 0; const stack = [process.pid]; while (stack.length) { const p = stack.pop(); for (const k of kids[p] || []) { sum += rss[k]; n++; stack.push(k); } } return { mib: sum / 1024, n }; }; 
// file: bench.cjs usage: node bench.cjs <launch|context|page> <concurrency> <jobs> const http = require('http'); const { chromium } = require('playwright'); const { treeRss } = require('./lib.cjs'); const [mode, conc, jobs] = [process.argv[2], +process.argv[3], +process.argv[4]]; const server = http.createServer((req, res) => { const u = new URL(req.url, 'http://x'); if (u.pathname === '/img.svg') { setTimeout(() => { res.setHeader('content-type', 'image/svg+xml'); res.end(`<svg xmlns="http://www.w3.org/2000/svg" width="400" height="300"><rect width="400" height="300" fill="hsl(${u.searchParams.get('n') * 37 % 360} 70% 60%)"/></svg>`); }, 40); return; } const n = u.searchParams.get('n'); res.setHeader('content-type', 'text/html'); res.end(`<!doctype html><body style="margin:0;font:20px sans-serif"> ${Array.from({ length: 12 }, (_, i) => `<img src="/img.svg?n=${n * 12 + i}" width="300" height="200">`).join('')} <h1>job ${n}</h1>${'<p>lorem ipsum dolor sit amet</p>'.repeat(200)}</body>`); }); (async () => { await new Promise(r => server.listen(0, '127.0.0.1', r)); const base = `http://127.0.0.1:${server.address().port}`; let peak = 0, peakN = 0; const sampler = setInterval(() => { const t = treeRss(); peak = Math.max(peak, t.mib); peakN = Math.max(peakN, t.n); }, 100); const shared = mode === 'launch' ? null : await chromium.launch(); const sharedCtx = mode === 'page' ? await shared.newContext({ viewport: { width: 1280, height: 800 } }) : null; let next = 0; const times = []; async function job(n) { const t0 = Date.now(); const browser = shared || await chromium.launch(); const ctx = sharedCtx || await browser.newContext({ viewport: { width: 1280, height: 800 } }); const page = await ctx.newPage(); await page.goto(`${base}/?n=${n}`, { waitUntil: 'load' }); await page.screenshot({ fullPage: true }); await page.close(); if (!sharedCtx) await ctx.close(); if (!shared) await browser.close(); times.push(Date.now() - t0); } const t0 = Date.now(); await Promise.all(Array.from({ length: conc }, async () => { while (next < jobs) await job(next++); })); const wall = Date.now() - t0; clearInterval(sampler); if (shared) await shared.close(); server.close(); times.sort((a, b) => a - b); console.log(JSON.stringify({ mode, conc, msPerJob: Math.round(wall / jobs), p50LatencyMs: times[times.length >> 1], peakRssMiB: Math.round(peak), peakProcs: peakN })); })(); 

Time and memory

I ran each row three times, on the same machine within about an hour. Where three numbers are given they are the three runs (the third from the files extracted from this article). msPerJob is wall time divided by jobs, so it measures throughput. p50 latency is how long one job takes from start to finish.

mode concurrency ms per job p50 latency (ms) peak RSS (MiB) peak processes
launch 1 2215 / 2110 / 2210 2053 / 1953 / 1862 613 / 615 / 615 6
context 1 1661 / 1618 / 1712 1595 / 1580 / 1687 622 / 622 / 619 6–7
launch 2 1047 / 1140 / 1423 2052 / 2113 / 2430 1158 / 1135 / 1139 12
context 2 1090 / 987 / 1457 2304 / 1897 / 2596 899 / 896 / 896 7
context 4 888 / 759 / 834 3005 / 3109 / 3355 1251 / 1313 / 1263 9
page 4 823 / 780 / 795 2956 / 3149 / 3037 1235 / 1247 / 1180 9–10

What the numbers say:

  • At concurrency 1, a shared browser was about 0.5 s per job faster (roughly 22 %) in all three runs. That is the cost of starting and stopping Chromium on this machine. Memory was the same, because only one browser is alive at a time.
  • At concurrency 2, throughput overlapped (1.0–1.4 s per job for launch, 1.0–1.5 s for context; the third run was slower for both, because the host was busier). Memory did not overlap: launch used about 1140 MiB and context about 900 MiB in every run. Two browsers cost a second set of browser processes; two contexts do not.
  • Going from 2 to 4 contexts gave roughly 20–40 % more throughput in each run pair and cost another 350–420 MiB, while p50 latency per job rose from about 2 s to 3 s. On a host that is also running other things, more concurrency was not free.
  • page and context at concurrency 4 were within run-to-run noise of each other. The shared context did not make anything faster.

So the speed argument for a shared browser is real but modest here. I would not restructure a working service for 0.5 s. The memory argument shows up only once you run several jobs at once. The next two tests are the stronger reasons.

Isolation: what the next job inherits

The page mode is the cheapest to write and the one that hides a bug. A context holds cookies and storage. This script runs two jobs against the same server, once with a context per job and once with one shared context. The first job gets a cookie from the server and writes to localStorage; the second only reads.

// file: state.cjs const http = require('http'); const { chromium } = require('playwright'); const server = http.createServer((req, res) => { res.setHeader('content-type', 'text/html'); if (req.url === '/set') res.setHeader('set-cookie', 'sid=job1; Path=/'); res.end(`<h1>${req.headers.cookie || 'no cookie'}</h1>`); }); (async () => { await new Promise(r => server.listen(0, '127.0.0.1', r)); const base = `http://127.0.0.1:${server.address().port}`; const browser = await chromium.launch(); for (const mode of ['context per job', 'one shared context']) { const shared = mode === 'one shared context' ? await browser.newContext() : null; let seen; for (const url of ['/set', '/read']) { const ctx = shared || await browser.newContext(); const page = await ctx.newPage(); await page.goto(base + url); if (url === '/set') await page.evaluate(() => localStorage.setItem('draft', 'from job 1')); seen = await page.evaluate(() => `${document.querySelector('h1').textContent} | localStorage.draft=${localStorage.getItem('draft')}`); await page.close(); if (!shared) await ctx.close(); } console.log(mode.padEnd(20), '-> job 2 sees:', seen); if (shared) await shared.close(); } await browser.close(); server.close(); })(); 

Output:

context per job -> job 2 sees: no cookie | localStorage.draft=null one shared context -> job 2 sees: sid=job1 | localStorage.draft=from job 1 

With a shared context the second job sent job 1's cookie to the server and could read its localStorage. If your jobs render pages for different users, or the same site with different logins, that is a data leak, not a performance detail. Playwright's isolation docs make the same point: cleaning up between runs is easy to forget and some state, such as visited links, cannot be cleaned up, so starting from a new context is the safer default (Playwright: Isolation). I only tested cookies and localStorage; I did not test the HTTP cache or visited-link styling.

Forgetting to close

Contexts are cheap, but they are not free. This script uses one browser for all jobs and ends each job in one of three ways: ctx.close(), only page.close(), or nothing.

// file: growth.cjs usage: N=60 node growth.cjs <close-context|close-page-only|close-nothing> const http = require('http'); const { chromium } = require('playwright'); const { treeRss } = require('./lib.cjs'); const how = process.argv[2], N = +(process.env.N || 60); const server = http.createServer((req, res) => { res.setHeader('content-type', 'text/html'); res.end(`<!doctype html><h1>hello</h1>${'<p>lorem ipsum</p>'.repeat(300)}`); }); const fmt = () => { const t = treeRss(); return `${Math.round(t.mib)} MiB in ${t.n} processes`; }; (async () => { await new Promise(r => server.listen(0, '127.0.0.1', r)); const base = `http://127.0.0.1:${server.address().port}`; const browser = await chromium.launch(); console.log(how, 'start:', fmt()); for (let i = 1; i <= N; i++) { const ctx = await browser.newContext(); const page = await ctx.newPage(); await page.goto(base); await page.screenshot(); if (how === 'close-context') await ctx.close(); else if (how === 'close-page-only') await page.close(); if (i % 20 === 0) console.log(how, `after ${i}:`, fmt()); } await browser.close(); server.close(); })(); 

Output from the files extracted from this article:

close-context start: 226 MiB in 5 processes close-context after 20: 305 MiB in 5 processes close-context after 40: 310 MiB in 5 processes close-context after 60: 307 MiB in 6 processes close-page-only start: 228 MiB in 5 processes close-page-only after 20: 326 MiB in 6 processes close-page-only after 40: 328 MiB in 6 processes close-page-only after 60: 340 MiB in 6 processes close-nothing start: 252 MiB in 5 processes close-nothing after 20: 2406 MiB in 25 processes 

I had also run close-nothing for all 60 jobs earlier with the same logic: 4505 MiB in 45 processes after 40 and 6612 MiB in 65 processes after 60.

  • close-context stays flat after the first 20 jobs.
  • close-page-only grows slowly, about 100 MiB over 60 jobs. The pages are gone but the contexts stay alive. That is small enough to miss in a test and large enough to matter in a service that runs for weeks.
  • close-nothing adds one renderer process and about 105 MiB per job. Every job left one open page, and each open page here had its own renderer process. The 20-job number reproduced three times (2400, 2408 and 2406 MiB). I ran the 60-job case only once, and it used most of the free memory on the host, so do not run it on a machine you care about.

If a job can throw between newContext() and close(), put the close in a finally. If it can hang, close the context from a timer. Closing the context also closes its pages, so you do not need to track them separately.

What I would use

  • One long-lived browser and a new context per job as the default. It is a little faster than a browser per job, uses less memory when jobs overlap, and nothing carries over between jobs.
  • A browser per job only if you need hard isolation from a crashing page taking down its neighbours, or cannot trust a long-lived browser to stay healthy. I did not test crashes or long-running browser degradation, so I cannot say how often either happens.
  • Keep concurrency small and measure on the host you deploy to. The jump from 2 to 4 here bought less than the jump from 1 to 2.
  • Count open contexts: browser.contexts().length should return to 0 when the service is idle.

Not covered: Firefox and WebKit, headed Chromium, real pages with scripts and large images, Docker memory limits, and browsers that have been up for days.

Written with AI assistance. Every code sample was run on 2026-11-11 with Playwright 1.63.0 and Chromium 153.0.8010.12 (chrome-headless-shell) on Node v24.22.2; the output above is from that run.