all posts

How to run Puppeteer in a sandbox

Ajay Kumar··9 min read

Puppeteer on your laptop is a delight. Puppeteer on a server is a browser — a large, stateful, resource-hungry program that leaks processes, writes to shared memory, and occasionally refuses to die. Running it inside a disposable VM turns most of that from an operational problem into a lifecycle problem, which is much easier.

This is the practical version: getting a browser binary, launching it with the right flags, driving it, getting artefacts back out, and making sure nothing is left behind. I build PandaStack, so the sandbox API is ours; the Puppeteer parts apply to any isolated environment.

Why a sandbox rather than a container on your app server

  • The page is an untrusted input. You are executing JavaScript written by whoever controls the site. That is truer still when a model decides which pages to visit — a page can contain text aimed at your agent, and following it should not be able to reach anything else you run.
  • Browsers leak. Zombie chrome processes, profile directories, and temporary files accumulate on any long-lived host running many sessions. A VM you delete after each session cannot accumulate anything.
  • Resource blast radius. A page with a runaway script or an enormous image will consume whatever memory you let it. Contained in a VM, that is one dead sandbox; on a shared host, it is whatever else was on that host.
  • Reproducibility. Every session starts from the same snapshot, so a failure is reproducible rather than a function of what ran before it.

Step 1: get a browser binary

Puppeteer normally downloads its own Chrome build on install. That works and it costs you a download on every cold environment. If your image already has a Chromium — the browser template here ships one installed via Playwright, along with the system libraries it links against — the faster path is puppeteer-core pointed at the existing binary.

# Option A: use the Chromium that is already installed.
# Ask Playwright where it put it, rather than guessing at a path.
node -e "console.log(require('playwright').chromium.executablePath())"
# /root/.cache/ms-playwright/chromium-1234/chrome-linux/chrome

npm i puppeteer-core   # no browser download

# Option B: let Puppeteer manage its own browser. Simpler, slower to start
# cold, and it needs the same system libraries to be present.
npm i puppeteer
Resolve the path at runtime rather than hardcoding it. Chromium build directories carry a version number that changes when the image is rebuilt, and a hardcoded path is a deploy that works until the day the template is updated.

Step 2: launch flags that matter, and one that does not

import puppeteer from "puppeteer-core";
import { chromium } from "playwright";

const browser = await puppeteer.launch({
  executablePath: chromium.executablePath(),
  headless: true,
  args: [
    // Chrome's own renderer sandbox needs user namespaces, which are often
    // unavailable inside a container. In a microVM the isolation boundary is
    // the hypervisor, so disabling Chrome's internal sandbox does not remove
    // your security boundary — it removes a redundant inner one. Do NOT do
    // this on a shared host where Chrome's sandbox IS the boundary.
    "--no-sandbox",

    // Chrome uses /dev/shm for renderer memory. Many container runtimes cap
    // it at 64MB, and a heavy page will crash the tab with an opaque error.
    // This flag makes Chrome use /tmp instead.
    "--disable-dev-shm-usage",

    // Small, real savings on a server with no GPU and no user watching.
    "--disable-gpu",
    "--disable-extensions",
    "--disable-background-timer-throttling",
    "--disable-renderer-backgrounding",
  ],
});

const page = await browser.newPage();

// Set these before navigating, not after. A site that fingerprints on the
// first request has already seen the default headless user agent otherwise.
await page.setViewport({ width: 1280, height: 800 });
await page.setUserAgent(
  "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 " +
  "(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
);
Be deliberate about --no-sandbox. Inside a microVM, where the hypervisor is your isolation boundary, it is a reasonable and common choice. On a shared host running your application, Chrome's renderer sandbox may be the only thing standing between a malicious page and your process — disabling it there is a real downgrade, not a workaround.

Step 3: drive it from outside

The pattern that keeps things simple: write a script into the sandbox, run it, read the artefacts back, delete the sandbox. The browser never runs in your application process and nothing has to be cleaned up on your side.

from pandastack import Sandbox

SCRIPT = """
import { chromium } from "playwright";
import puppeteer from "puppeteer-core";

const browser = await puppeteer.launch({
  executablePath: chromium.executablePath(),
  headless: true,
  args: ["--no-sandbox", "--disable-dev-shm-usage"],
});

try {
  const page = await browser.newPage();
  await page.setViewport({ width: 1280, height: 800 });

  // networkidle2 is usually the wrong default: it waits for quiet, and many
  // pages never go quiet (polling, analytics beacons, websockets). Wait for
  // the thing you actually need instead.
  await page.goto(process.argv[2], { waitUntil: "domcontentloaded", timeout: 30000 });
  await page.waitForSelector("main", { timeout: 10000 });

  await page.screenshot({ path: "/workspace/shot.png", fullPage: true });
  const title = await page.title();
  console.log(JSON.stringify({ title }));
} finally {
  // finally, not after — an exception above must still close the browser,
  // or the process hangs holding a browser nobody is using.
  await browser.close();
}
"""

# ttl_seconds is the guarantee: even if this process dies mid-run, the
# sandbox is reaped rather than left running a browser forever.
with Sandbox.create(template="browser", ttl_seconds=600) as sb:
    sb.filesystem.write("/workspace/shot.mjs", SCRIPT)
    out = sb.exec(
        "cd /workspace && npm i puppeteer-core --silent && "
        "node shot.mjs https://example.com",
        timeout_seconds=120,
    )
    print(out.stdout)
    sb.filesystem.download("/workspace/shot.png", "shot.png")

The five failures you will actually hit

  1. Failed to launch the browser process, and a missing .so in the error. The image lacks a shared library Chrome links against — libnss3, libatk-bridge, libgbm, and libasound are the usual suspects. Use an image built for browsers rather than adding libraries one error at a time.
  2. Target closed or Session closed part-way through. Frequently the renderer being killed for memory. Try --disable-dev-shm-usage first, then give the sandbox more RAM. A page with large images will use more than you expect.
  3. Navigation timeout of 30000ms exceeded on a page that clearly loaded. You are almost certainly waiting for networkidle on a page with a persistent connection. Wait for a selector you need instead of for silence.
  4. The script finishes and the process hangs. A browser that was never closed, because an exception skipped the close call. Put it in a finally block, always.
  5. It works locally and gets blocked in the sandbox. Datacentre IP ranges and default headless fingerprints are both detectable. Set a realistic user agent and viewport before navigating, and if you are scraping at any volume, respect robots.txt and rate limits rather than escalating an arms race.

Step 4: concurrency, and the memory arithmetic

The question people get wrong is how much to run in one environment. A headless Chrome with one page is a few hundred megabytes under load; a heavy page can be considerably more. Multiply before you decide.

Two models, and they are not equivalent:

  Many pages, one browser, one sandbox
      Cheaper per page. Pages share a browser process, so a crash in
      one can take the others with it, and cookies and storage are
      shared unless you use separate browser contexts. Fine for
      scraping a list of pages you trust to be boring.

  One page, one browser, one sandbox
      More expensive per page, and completely isolated: a crash, a
      memory blowup, or a hostile page affects exactly one job. This
      is the right model when the pages are untrusted or when an
      agent is choosing where to go.

Use browser.createBrowserContext() for the middle ground: separate
cookie jars and storage inside one browser process. It is isolation of
state, not of resources — a crashing renderer still takes the browser
down with it.

The short version

Point puppeteer-core at a Chromium that is already in the image, launch with --disable-dev-shm-usage and a deliberate decision about --no-sandbox, wait for selectors rather than network silence, and close the browser in a finally block.

Then let the sandbox be your cleanup. Set a TTL at creation, delete it when the job is done, and stop thinking about zombie chrome processes — which is most of what makes browser automation unpleasant to operate in the first place.

Frequently asked questions

Do I need --no-sandbox to run Puppeteer in a container?

Usually yes in a container, and it is worth understanding what you are giving up. Chrome's renderer sandbox relies on user namespaces and seccomp facilities that many container runtimes restrict by default, so Chrome fails to launch without the flag. Disabling it means a compromised renderer is no longer contained by Chrome itself, which matters a great deal if that renderer is running on the same host as your application. The distinction is what the outer boundary is: inside a microVM, the hypervisor separates the browser from everything else, so Chrome's internal sandbox is a redundant second layer and disabling it is a reasonable trade. On a shared application host it may be the only layer you have. Decide based on that, rather than copying the flag from a Stack Overflow answer.

Why does Puppeteer crash with a 'Target closed' error?

Most often because the renderer process was killed, and memory is the usual reason. Chrome uses /dev/shm for shared renderer memory, and many container runtimes cap it at 64MB, which a media-heavy page will exhaust quickly — launching with --disable-dev-shm-usage moves that allocation to /tmp and fixes a large share of these crashes. If it persists, the environment genuinely needs more RAM: budget several hundred megabytes for the browser plus whatever the page itself demands, which for a heavy application can be a lot more than expected. Two less common causes worth checking are calling browser.close() while an operation is still in flight, and a platform-level timeout killing the whole environment out from under a long-running navigation.

Should I use Puppeteer or Playwright?

For new work, Playwright has the edge on the things that cause day-to-day pain: auto-waiting on selectors that removes a whole category of flaky timing code, first-class support for Chromium, Firefox, and WebKit rather than Chrome alone, better tracing and debugging tools, and browser contexts that are cheap to create for isolation. Puppeteer remains excellent, is closer to the Chrome DevTools Protocol if you need low-level control, and has an enormous amount of existing code and community knowledge around it. The pragmatic answer is that a working Puppeteer codebase is not worth rewriting, and a greenfield automation project should probably start with Playwright. They can also coexist — puppeteer-core will happily drive a Chromium that Playwright installed, which is a useful trick when your image already has one.

How much memory does a headless Chrome need?

Budget a few hundred megabytes for the browser with a single simple page, and expect a heavy modern web application to want considerably more — a page with large images, video, or an ambitious single-page framework can push past a gigabyte on its own. Multiple pages in one browser share some overhead but each renderer adds real memory, so ten concurrent pages is not ten times cheaper than ten browsers but it is not free either. The practical approach is to measure with your actual target pages rather than a benchmark, since the variance between sites is far larger than the variance between configurations. If you are running one page per environment, sizing at two to four gigabytes covers almost everything; if you are packing many pages into one, measure the worst page you expect and multiply.

How do I stop Puppeteer leaving zombie browser processes behind?

Three layers, and you want all of them because each catches a different failure. In the code, close the browser in a finally block so an exception during navigation cannot skip the cleanup — this is the single most common source of leaked processes. Around the code, run the browser in a disposable environment you delete when the job finishes, so anything that survived the process is destroyed with the machine. And underneath both, set a TTL when creating that environment so it expires on its own even if your orchestrating process is killed before it can clean up. On a long-lived shared host you would additionally need a reaper that hunts for orphaned chrome processes, which is exactly the operational chore that a per-job sandbox removes.

Keep reading

Run code in a microVM in one API call.

49ms p50 cold start. Fork, snapshot, and scale to zero.

Start free
Written by Ajay Kumar, Founder, PandaStack.