Puppeteer Screenshot Memory Leak: Close Pages and Browser Correctly
Stop Puppeteer screenshot jobs from retaining pages, contexts, or browsers. Learn which resource to close, when to dispose handles, and how to clean up reliably.
If Puppeteer memory keeps growing during screenshot work, close the resource your job owns: call page.close() for a page, context.close() for an isolated task context and all its pages, or browser.close() when the job owns the whole browser. Await page.screenshot() before cleanup, and dispose ElementHandle objects returned by selector APIs when finished. browser.disconnect() only detaches Puppeteer; it does not close Chrome or its pages.
These are the documented lifecycle rules, not proof that every rise in memory is caused by an unclosed page. Puppeteer’s documentation does not promise a general memory reduction amount or say that cleanup alone fixes every memory issue.
1. Close a standalone screenshot browser in a finally block
When one process launches Chrome to take a screenshot and owns that browser, close it even if navigation or capture fails. A finally block makes cleanup run on both success and error paths.
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch();
try {
const page = await browser.newPage();
await page.goto('https://example.com');
await page.screenshot({ path: 'screenshot.png' });
} finally {
await browser.close();
}
This follows Puppeteer’s documented screenshot flow: launch, create a page, navigate, capture, and close the browser. The try/finally wrapper ensures the owned browser is closed if an awaited step throws. Puppeteer screenshot guide · Browser.close() API
2. Choose the cleanup boundary for a long-lived browser
A service that reuses one browser for multiple jobs should not close the browser after every screenshot if it needs that browser for later work. Instead, create an isolated BrowserContext for a job and close the context when that job ends. Closing a context closes all pages in it. If the job uses the default context, close the individual page.
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch();
async function captureJob(url, outputPath) {
const context = await browser.createBrowserContext();
try {
const page = await context.newPage();
await page.goto(url);
await page.screenshot({ path: outputPath });
} finally {
await context.close();
}
}
try {
await captureJob('https://example.com', 'one.png');
await captureJob('https://pptr.dev', 'two.png');
} finally {
await browser.close();
}
The browser remains available between jobs; each task’s context and pages are closed at the task boundary. If you create a page in the default context, use page.close() instead. The default BrowserContext cannot itself be closed. Puppeteer browser management · Page.close() API
3. Dispose ElementHandles returned by selector APIs
When using lower-level selector methods such as waitForSelector(), the result is an ElementHandle. Dispose it when you are done with it, including when the subsequent action throws.
const element = await page.waitForSelector('div > .class-name');
try {
await element?.click();
} finally {
await element?.dispose();
}
The optional chaining handles a nullable result in application code. Adapt error handling and selector behavior to your use case. Puppeteer’s page-interactions guide explicitly warns that the resulting handle must be manually disposed to prevent memory leaks. Puppeteer page interactions
4. Understand what each lifecycle method closes
| Call | What it closes | Use it when |
|---|---|---|
await page.close() |
The individual page | The browser is shared or will continue serving work. |
await context.close() |
The BrowserContext and every page in it | A task owns an isolated context. The default context cannot be closed. |
await browser.close() |
The browser and all associated pages | The process or task owns the browser and is finished with it. |
browser.disconnect() |
Detaches the Puppeteer connection; leaves the browser running | You intentionally want the browser to remain alive and be managed elsewhere. |
All of these asynchronous cleanup operations should be awaited. In particular, do not use disconnect() as a substitute for closing a browser process your screenshot job owns. Browser management documentation
5. Await the screenshot before cleanup
page.screenshot() is asynchronous. Await its promise before starting the cleanup sequence so the screenshot completes before the page, context, or browser is closed. Puppeteer also documents synchronization between screenshots and Page.close() in a BrowserContext: relevant page creation and close operations wait for an active screenshot to finish. Awaiting the capture explicitly still makes the job’s ordering clear and lets screenshot errors reach your error handling.
const image = await page.screenshot({ type: 'png' });
await page.close();
// `image` is a Buffer and the page has now been closed.
Page.screenshot() API reference
6. Diagnose growth without assuming its cause
- Check ownership: for every page, context, and browser, identify which job is responsible for closing it.
- Check all exit paths: navigation, selector waits, and screenshot capture can reject. Put cleanup in
finally. - Inspect handles: dispose
ElementHandleobjects when finished, especially those kept while a job runs. - Distinguish detach from shutdown: verify that code uses
close()at the intended ownership boundary rather thandisconnect(). - Observe behavior over repeated jobs: cleanup is a necessary lifecycle practice, but rising memory alone does not establish a specific leak or its cause.
The cited Puppeteer guidance documents handle disposal and lifecycle behavior. It does not state a universal memory reduction figure or guarantee that closing resources will resolve all memory growth.
7. Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Chrome stays running after the script exits its capture logic | The code called browser.disconnect(), or omitted browser cleanup. |
If the job owns the process, await browser.close() in a finally block. Disconnect only when another owner should keep the browser alive. |
| A shared browser loses pages needed by other jobs | The job closed the whole browser when it owned only one page or context. | Close the job’s page or isolated context; reserve browser closure for the browser owner. |
| Pages from completed jobs remain open | Page/context cleanup is missing on a success or error path. | Put the appropriate awaited close call in finally. |
| Memory continues to rise despite closing pages | The available evidence does not identify the cause; retained handles or another part of the application may need investigation. | Dispose selector handles and audit ownership and references. Do not infer that page cleanup guarantees a fix or a specific memory reduction. |
| Screenshot output is incomplete or capture throws during shutdown | Cleanup began before the screenshot promise was awaited, or the capture itself failed. | Await page.screenshot(), then clean up in finally; handle the capture error at the job boundary. |
context.close() fails for the default context |
The default BrowserContext cannot be closed. | Close the page in the default context, or create an isolated context for a task that should be closed as a unit. |
8. Performance, reliability, and cost considerations
Reusing a browser across jobs can avoid repeatedly launching a browser, while closing each job’s page or isolated context gives that job a clear cleanup boundary. This is a design tradeoff, not a documented performance benchmark or memory guarantee. Closing the browser ends all associated pages, so only do it when the owner is finished.
For reliability, await navigation and capture operations, and put owned-resource cleanup in finally. The cited Puppeteer sources provide lifecycle guidance but no quantified memory savings, universal diagnosis, uptime claim, or cost comparison. Hosting and browser runtime costs depend on your deployment and are outside the documented lifecycle behavior.
9. Or skip the browser setup
If your goal is a screenshot rather than operating a browser, ScreenshotNeo is a website screenshot API and MCP server. One GET request returns an image or PDF; its API supports PNG, JPEG, and WebP. See the ScreenshotNeo docs.
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests
r = requests.get(
"https://api.screenshotneo.com/v1/shot",
params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"},
timeout=90,
)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
const bytes = Buffer.from(await res.arrayBuffer());
await import('node:fs/promises').then(fs => fs.writeFile('shot.webp', bytes));
Cookie banners are accepted and removed before the shot, along with known newsletter popups and chat widgets; each step can be turned off. Bot checks, blank pages, and failed loads are never billed, and responses indicate the page verdict and billed status. Its MCP server gives AI agents screenshot, page-info, and PDF tools. The free plan includes 1,000 screenshots a month with no card; paid plans start at $5 for 3,000. Sign up free for 1,000 screenshots a month, no card required.
10. FAQ
Does closing a page close the browser?
No. page.close() closes that page; use browser.close() to close the browser and its associated pages.
Can I close a BrowserContext for every job?
Yes, when each job uses an isolated context. The default BrowserContext cannot be closed.
Does Puppeteer say closing pages fixes every memory leak?
No. The docs explain resource lifecycle and warn about disposing ElementHandles, but they do not guarantee that those steps fix every cause of memory growth.


