How to Run Puppeteer on Netlify
Run Puppeteer in a Netlify Function with a compatible Linux Chromium binary, correct dependency packaging, and production checks for time, memory, and bundle size.
Short answer: Put Puppeteer in a Node.js Netlify Function and make a compatible Chromium executable available in the deployed function. A practical serverless setup is puppeteer-core plus @sparticuz/chromium. Configure Puppeteer with that package’s executable path and launch arguments, package the required dependencies, then test the deployed function itself. A browser installed on your laptop does not prove that Chromium is present or compatible in Netlify’s Linux runtime.
Netlify’s browser-prerendering example uses Puppeteer with @sparticuz/chromium in a serverless function. The function runs in an ephemeral environment, so return the result in the response or send it to suitable storage rather than relying on a local file surviving another invocation. See Netlify Functions documentation and the @sparticuz/chromium project documentation.
1. Create a Netlify Function
Netlify’s default functions directory is netlify/functions/. A function named render-page.js is normally available at /.netlify/functions/render-page. If your project configures a different functions directory, place the file there instead.
your-project/
├── netlify.toml
├── package.json
└── netlify/
└── functions/
└── render-page.js
This example accepts a page URL, opens it in Chromium, captures a PNG, and returns the bytes as the function response. It includes basic input validation, navigation timeouts, and cleanup.
// netlify/functions/render-page.js
import puppeteer from "puppeteer-core";
import chromium from "@sparticuz/chromium";
const MAX_URL_LENGTH = 2_048;
export default async function handler(request) {
if (request.method !== "GET") {
return new Response("Method not allowed", {
status: 405,
headers: { Allow: "GET" },
});
}
const target = new URL(request.url).searchParams.get("url");
if (!target || target.length > MAX_URL_LENGTH) {
return new Response("Provide a URL no longer than 2048 characters.", {
status: 400,
});
}
let parsed;
try {
parsed = new URL(target);
} catch {
return new Response("The url parameter must be a valid absolute URL.", {
status: 400,
});
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
return new Response("Only http and https URLs are supported.", {
status: 400,
});
}
let browser;
try {
browser = await puppeteer.launch({
args: chromium.args,
defaultViewport: chromium.defaultViewport,
executablePath: await chromium.executablePath(),
headless: "shell",
});
const page = await browser.newPage();
page.setDefaultNavigationTimeout(25_000);
await page.goto(target, { waitUntil: "networkidle2" });
const png = await page.screenshot({
type: "png",
fullPage: true,
});
return new Response(png, {
status: 200,
headers: {
"Content-Type": "image/png",
"Cache-Control": "no-store",
},
});
} catch (error) {
console.error("Puppeteer capture failed", error);
return new Response("Unable to capture the requested page.", {
status: 502,
});
} finally {
if (browser) await browser.close();
}
}
The example uses the current documented shape of @sparticuz/chromium’s API. Package APIs and supported launch modes can change, so check the README and release notes for the exact versions you pin. Select a Chromium package version compatible with the browser version supported by your Puppeteer version. Puppeteer’s documentation explains the configuration and launch options.
2. Install and package the browser dependencies
Install the libraries from the project root and commit the resulting lockfile:
npm install puppeteer-core @sparticuz/chromium
puppeteer-core does not download and manage a browser for you. The function therefore explicitly supplies executablePath and the serverless launch arguments from @sparticuz/chromium. The full puppeteer package is another strategy: its install downloads a preferred browser, but that browser still has to be included in the built function artifact and work in the deployed environment. Do not assume that installing either library alone makes a usable browser available.
Netlify’s build system does not recursively install dependencies in separate, unbundled function folders. Keep dependencies where the build can include them, or follow Netlify’s documented deployment setup for installing dependencies during the build, such as a prebuild or postinstall script. Confirm the final artifact contains the function’s dependencies and Chromium assets. See Netlify’s functions setup guide.
The Chromium package documents a compressed bundle over 50 MB. That can affect packaging and deployment limits. The project also provides @sparticuz/chromium-min for environments where the pack is hosted separately; that option adds the operational requirement to make the compatible pack available at runtime. Check the package’s current documentation and your project’s applicable limits before choosing it.
3. Configure the function and test it
A minimal netlify.toml can declare the functions directory and Node runtime version. Use a runtime supported by both your project and the Chromium package version you select.
[build]
functions = "netlify/functions"
[functions]
node_bundler = "esbuild"
[build.environment]
NODE_VERSION = "20"
These settings are examples, not a guarantee that every package release supports that Node version. Check your Netlify runtime configuration and package requirements. If your repository already defines these settings, adapt the existing configuration rather than adding conflicting sections.
- Run the site and function locally with Netlify Dev, then call
http://localhost:8888/.netlify/functions/render-page?url=https%3A%2F%2Fexample.com. - Deploy the function and invoke its deployed URL using a page you are permitted to access.
- Inspect the function logs if launch, navigation, or image generation fails. Confirm that the deployment artifact contains Chromium and the function dependencies.
- Try a slow page and a page with long-running network requests. Tune the navigation strategy and timeout to the workload while staying within the function execution limit.
Netlify documents a default function memory allocation of 1024 MB and a 60-second synchronous execution limit; memory is configurable, while the cited synchronous limit is not configurable. Confirm your current project and account settings before relying on these values. See Netlify function configuration. Large pages, slow navigation, and PDF generation can use more time and memory than a small screenshot.
4. Choose a navigation and capture strategy
The example uses networkidle2, which waits for network activity to become quiet. Sites with analytics, streaming requests, long polling, or continuously loaded content may never reach a useful idle state. For those pages, use a more bounded approach, such as waiting for domcontentloaded and then waiting for a specific selector or a short delay.
// Bounded navigation followed by a known page element
await page.goto(target, { waitUntil: "domcontentloaded", timeout: 20_000 });
await page.waitForSelector("main", { timeout: 8_000 });
await page.screenshot({ path: "/tmp/page.png", fullPage: true });
Use page.screenshot() for image output. For PDF output, use page.pdf() and provide the paper format and print options that match your use case. Large full-page images and PDFs increase memory use; test representative pages and avoid keeping multiple browser pages open unless the workload requires it.
For local development on macOS or Windows, the Linux-only serverless Chromium package may not run as your local browser. Use a locally installed Chrome or Chromium executable for local development and the package executable in the deployed Linux function. Keep those paths environment-specific; do not let the local browser path silently become the production path.
5. Secure the function before accepting arbitrary URLs
A public screenshot endpoint that accepts any URL can be abused to make requests from your function to internal services or large external resources. The example validates URL syntax and scheme, but that alone is not a complete defense against server-side request forgery.
- Restrict captures to an allowlist of domains if the endpoint is for a fixed application or customer set.
- Reject loopback, private, link-local, and other non-public destinations, including after DNS resolution and redirects.
- Limit URL length, navigation time, response size, and concurrent work.
- Require authorization or rate limits before exposing a capture function publicly.
- Avoid forwarding secrets or privileged cookies to user-selected destinations.
- Do not accept arbitrary JavaScript or headers from untrusted callers.
For a private internal tool, the access controls may be provided by the surrounding application. For a public endpoint, implement and review destination controls before deployment.
6. Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
Browser was not found or executable path errors |
The Chromium binary was not packaged, extraction failed, or the configured path is wrong. | Use await chromium.executablePath(), pass it as executablePath, and inspect the deployed artifact and function logs. |
| Works locally but fails after deploy | Local Chrome differs from the deployed Linux browser, or deployment omitted a dependency or binary. | Test the deployed function and check its logs. Verify the lockfile, bundler output, runtime, and Chromium assets. |
Target closed or browser crashes |
Memory pressure, incompatible browser/library versions, or too many concurrent pages. | Match Chromium and Puppeteer versions, reduce page size or concurrency, close pages, and check function memory and logs. |
| Navigation timeout | The site is slow, blocks automation, or keeps network connections open. | Set an explicit timeout, use domcontentloaded plus a selector wait, or choose a bounded delay appropriate to the page. |
| Blank or incomplete screenshot | The capture happened before client rendering, fonts, or lazy content finished loading. | Wait for the relevant selector or application-ready signal. For lazy content, scroll the page before capture and verify the target page actually rendered. |
| Function exceeds its time limit | Browser startup plus navigation and rendering exceed the synchronous execution budget. | Bound each wait, reduce capture scope, or move longer work to an asynchronous job architecture appropriate to your deployment. |
| Function bundle or deploy is too large | The Chromium package and dependencies exceed applicable packaging constraints. | Inspect current bundle limits and package size. Consider the documented minimal-package approach with its separately hosted pack, or another deployment architecture. |
| Import or module syntax error | ES modules, CommonJS, bundler settings, and package entry points do not agree. | Match the function file format and package configuration; check the build output rather than relying only on local execution. |
7. Performance, reliability, and cost
- Startup: Launching Chromium is work for each function invocation unless an execution environment is reused. Netlify functions are ephemeral, so do not rely on a warm process or local state for correctness.
- Bounded waits: Prefer waiting for a meaningful page condition over an unbounded network-idle wait. Set navigation and selector timeouts below the function’s overall limit so there is time to return an error response and close the browser.
- Memory: Full-page screenshots and PDFs can consume substantially more memory than viewport captures. Keep concurrency controlled and close pages and browsers in cleanup paths.
- Compatibility: Pin dependencies in a lockfile and upgrade Puppeteer and Chromium together only after checking their supported browser versions. Recheck after package upgrades.
- Cost: The dossier does not establish a price for this specific workload. Account for your Netlify plan and execution usage, plus any separately hosted browser pack or storage. Measure representative pages in your own deployed environment rather than extrapolating from local timings.
- Reliability: Return clear HTTP errors for invalid input and failed captures, log operational details without leaking secrets, and test the deployed function. If the caller needs retries, make them bounded; repeated retries against a slow or failing page can increase runtime and cost.
Or skip the browser setup
If your goal is to get a screenshot from a URL rather than operate Chromium inside a function, ScreenshotNeo provides a screenshot API and MCP server. One GET request returns an image or PDF, with setup details in the ScreenshotNeo API documentation.
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,
)
r.raise_for_status()
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}`);
if (!res.ok) throw new Error(`Screenshot request failed: ${res.status}`);
await Bun.write('shot.webp', res);
Cookie and consent banners, newsletter popups, and chat widgets are removed before capture. Bot checks, blank pages, and failed loads are never billed. The MCP server lets AI agents use take_screenshot, get_page_info, and capture_pdf. The free plan includes 1,000 screenshots a month with no card; paid plans start at $5 for 3,000 screenshots. Create a free ScreenshotNeo account.
FAQ
Should I use Puppeteer or puppeteer-core?
Use puppeteer-core when you manage the browser executable separately, as in this serverless Chromium example. The full puppeteer package manages a browser download, which still has to be available and usable in the deployed function.
Can I run the serverless Chromium package on my laptop?
The package documents a Linux build. On macOS or Windows, point local development at an installed browser and use the serverless executable in the deployed environment.
Can I save screenshots to a local file?
You can write temporary files during an invocation, but the function environment is ephemeral. Return the bytes or store the result using storage designed to persist between invocations.
Is 60 seconds enough?
It depends on browser startup, page behavior, capture size, and rendering. Treat the documented synchronous limit as a ceiling, not a target, and measure your deployed workload against current project settings.


