Puppeteer context isolation: shared vs incognito contexts for parallel automation
Choose between a shared Puppeteer context and incognito contexts for parallel automation. Compare isolation, memory, startup cost and safety, then use a bounded context pool with proper cleanup and validation.
25 Dec 2025, 11:08 UTC

The problem: parallel tasks leaking state in Puppeteer
Running multiple scrapes, tests, or multi-tenant automations from one Puppeteer browser instance creates a choice. Use the default shared context and accept state leakage between pages, or create isolated incognito contexts per task and pay memory and startup cost. The wrong choice shows up as cross-user cookies, polluted localStorage, flaky logins, or out-of-memory crashes under load.
A BrowserContext in Puppeteer is a profile-like container. It holds cookies, localStorage, sessionStorage, cache, permissions and open pages. The default context is browser.contexts()[0]. Incognito contexts created with browser.createIncognitoBrowserContext() or browser.newContext() in Puppeteer 22+ are separate profiles inside the same browser process.
Isolation guarantees and limits
Incognito context gives per-context separation for storage and site data. Each context can host multiple pages. Closing the context destroys its pages and clears storage.
What is NOT isolated. Network-level state is browser-wide: DNS cache, HSTS, certificate decisions and some permission grants are shared. Browser extensions loaded at launch affect all contexts; you cannot load extensions per context. For strict network or extension isolation you need separate browser.launch() instances.
Firefox via puppeteer-firefox has limited context isolation. For multi-context workloads prefer Chromium.
Options compared
| Option | Isolation | Memory per unit | Startup latency | Cookie persistence | Parallel safety | Cleanup |
|---|---|---|---|---|---|---|
| Shared default context | None, all pages share storage | Lowest, ~30-50% less per context | None | Persistent | Race conditions on login/state | page.close() |
| Incognito BrowserContext | Full cookies, localStorage, sessionStorage, cache, permissions | ~15-25MB baseline per context + per-page overhead | +50-150ms per context | Ephemeral, cleared on close | Safe for parallel tasks | await context.close() |
| Separate browser.launch() | Process-level, strongest | 10x higher | Seconds | Per process | Safe | await browser.close() |
Practical limits for incognito contexts are OS file descriptors and memory. A typical Chromium instance supports ~50-100 contexts before instability.
When to choose what
Shared context is acceptable for sequential single-user flows where you want persistent login and minimal overhead, e.g., a single test suite that reuses auth.
Incognito contexts fit parallel scraping, multi-tenant automation, and test isolation where each task needs clean cookies and storage. Use a bounded pool to cap memory.
Separate browser instances are reserved for untrusted code execution or when you need network stack isolation and extension separation.
Concrete context pool pattern
Create a pool at startup, acquire a context per task, release it back. Always await context.close() in finally to avoid leaks.
// run with node >=18, permissions: write access to working directory
import puppeteer from 'puppeteer';
async function createContextPool(browser, size) {
const pool = [];
for (let i = 0; i < size; i++) {
// Puppeteer 22+ alias: browser.newContext()
const ctx = await browser.createIncognitoBrowserContext();
pool.push({ ctx, inUse: false });
}
return {
async acquire() {
while (true) {
const item = pool.find(p => !p.inUse);
if (item) { item.inUse = true; return item.ctx; }
await new Promise(r => setTimeout(r, 10));
}
},
async release(ctx) {
const item = pool.find(p => p.ctx === ctx);
if (item) item.inUse = false;
},
async closeAll() {
await Promise.all(pool.map(p => p.ctx.close()));
}
};
}
const browser = await puppeteer.launch({ headless: true });
const pool = await createContextPool(browser, 10);
const ctx = await pool.acquire();
try {
const page = await ctx.newPage();
await page.goto('https://example.com');
// task logic here
} finally {
await pool.release(ctx);
}
await pool.closeAll();
await browser.close();
Risk: forgetting to await context.close() causes memory leaks and file descriptor exhaustion. Wrap acquisition in try/finally.
Validation checklist
Verify isolation without assuming output.
- Version check. Run npm list puppeteer in the project root. Ensure >=22.0.0 for browser.newContext() alias; older versions require createIncognitoBrowserContext().
- Storage separation. Create two incognito contexts, open a page in each, set localStorage to different values via page.evaluate, then read back in both pages. Expect different values, confirming isolation.
- Cookie separation. Use page.cookies() before and after a login in one context, then inspect cookies from the other context. Expect no cross-contamination.
- Cleanup verification. After context.close(), list browser.contexts(). The closed context should be absent. Operations on pages from a closed context should fail with a protocol target closed error.
- Memory monitoring. Run a bounded set of contexts with pages, then check process.memoryUsage().heapUsed. Linear growth across iterations suggests unclosed contexts.
Headless mode uses the same context behavior, but some permissions APIs behave differently. Test both headless and headed if production targets differ.
Puppeteer Cluster abstracts pooling but adds an abstraction layer. Direct pool management is simpler for a fixed concurrency target.