Updated
Puppeteer on AWS Lambda and Vercel: Fix Chromium Size, Timeout and Cold Start Errors
Puppeteer screenshots are simple on your laptop and awkward the moment you deploy to a serverless platform. The browser is too big for the bundle, it needs libraries the runtime does not have, and it competes with function timeouts. This guide shows the working Puppeteer setup for AWS Lambda and Vercel, the errors you will probably hit, and the alternative of making one HTTP request instead.
Why Puppeteer breaks on serverless
A normal npm install puppeteer downloads a full Chromium build into a cache folder. That works on a laptop or a VM. A serverless function is different: your code ships as a size-limited bundle, runs on a read-only filesystem apart from a small temp directory, has no system fonts, and is frozen or killed on a timer. A browser that needs hundreds of megabytes of files plus shared libraries is a bad fit for that model.
Most "Puppeteer does not work on Lambda" errors come from four constraints:
- Package size. Lambda accepts a 50 MB zipped bundle for direct upload and 250 MB unzipped, layers included. Stock Chromium does not fit.
- Missing system libraries. The Lambda runtime image is minimal, so Chromium fails to start with errors about missing
.sofiles such aslibnss3.so. - Memory and CPU. Lambda allocates CPU in proportion to memory. A small function renders slowly and may crash on a heavy page.
- Time and payload limits. Cold starts, slow pages and large responses all fight against the function timeout and the 6 MB synchronous response limit.
Everything below is a way around one of those four limits. If you would rather not own them, skip to the API option.
1. Puppeteer on AWS Lambda with @sparticuz/chromium
The common route is to replace the puppeteer package with puppeteer-core (no browser download) and pair it with @sparticuz/chromium, a Chromium build compressed to fit serverless limits and unpacked into /tmp at runtime. It also bundles the shared libraries the Lambda runtime lacks.
Install both packages and pin exact versions of each (no ^ ranges). The @sparticuz/chromium major version is the Chromium version, so pair it with the puppeteer-core release that targets the same Chrome (see the Puppeteer Chromium support table). At the time of writing, puppeteer-core@25.11.0 targets Chrome 153 and matches @sparticuz/chromium@153.0.0. Mismatched pairs are a frequent source of launch errors:
npm install --save-exact puppeteer-core@25.11.0 @sparticuz/chromium@153.0.0A minimal handler for a Lambda Function URL that returns a full-page PNG. It reads the target from the JSON body, so the same code is what you test over HTTP:
import chromium from '@sparticuz/chromium'
import puppeteer from 'puppeteer-core'
// Demo guard: capture only hosts you control (see the SSRF section).
const ALLOWED_HOSTS = new Set(['example.com'])
export const handler = async (event) => {
// Function URL: the POST body arrives as a JSON string in event.body.
const { url } = JSON.parse(event.body ?? '{}')
let target
try { target = new URL(url) } catch { return { statusCode: 422, body: 'Invalid URL' } }
if (target.protocol !== 'https:' || !ALLOWED_HOSTS.has(target.hostname)) {
return { statusCode: 422, body: 'URL not allowed' }
}
const browser = await puppeteer.launch({
args: chromium.args,
executablePath: await chromium.executablePath(),
headless: 'shell',
})
try {
const page = await browser.newPage()
await page.setViewport({ width: 1440, height: 900 })
await page.goto(target.toString(), { waitUntil: 'networkidle2', timeout: 25_000 })
const png = await page.screenshot({ fullPage: true })
return {
statusCode: 200,
isBase64Encoded: true,
headers: { 'Content-Type': 'image/png' },
body: Buffer.from(png).toString('base64'),
}
} finally {
await browser.close()
}
}After deploying with a Function URL, test it with the command below. It works unsigned when the URL auth type is NONE; with AWS_IAM the request must be signed with SigV4 (for example with curl --aws-sigv4).
curl -X POST "$FUNCTION_URL" \
-H "Content-Type: application/json" \
-d '{"url":"https://example.com"}' \
--output shot.pngDeployment settings that matter: use a Node.js 22.17+ (22.x) or 24+ runtime, which is what @sparticuz/chromium@153.0.0 requires, set the architecture to x86_64 explicitly (the full package targets x86_64), start at around 1,536 MB of memory, and set the function timeout above your page timeout so Puppeteer fails before Lambda kills the process. Tune memory by measuring. If the zipped bundle is over 50 MB, upload it through S3 rather than directly; the 250 MB unzipped limit still applies.
Watch the response size. A buffered synchronous Lambda response is capped at 6 MB, and base64 encoding adds about a third. A tall full-page PNG can pass that limit easily. Write the image to S3 and return its URL instead of returning the bytes, or switch to WebP, which our WebP screenshot guide covers.
2. Puppeteer on Vercel and Next.js
Vercel adds two problems: the function bundle has a size limit, and Next.js tries to trace and bundle server dependencies. The usual answer is @sparticuz/chromium-min, which ships without the browser and downloads a Chromium pack from a URL at runtime. Install the same pinned pair, and download the matching pack from the Sparticuz GitHub release (for 153.0.0 it is chromium-v153.0.0-pack.x64.tar):
npm install --save-exact puppeteer-core@25.11.0 @sparticuz/chromium-min@153.0.0Upload that .tar to storage you control (object storage or Vercel Blob) and put its URL in CHROMIUM_PACK_URL; pointing production at a GitHub release URL ties your uptime to GitHub. Then mark the packages external so Next.js does not bundle them:
// next.config.ts
import type { NextConfig } from 'next'
const config: NextConfig = {
serverExternalPackages: ['@sparticuz/chromium-min', 'puppeteer-core'],
}
export default configserverExternalPackages is the option in current Next.js; older releases used experimental.serverComponentsExternalPackages. It opts the packages out of Next.js bundling, but Output File Tracing can still copy their files into the deployment, so it does not by itself keep you under the size limit. Measure the deployed function size. Use the same Node.js 22.17+ or 24+ runtime here. Then call Puppeteer from a Node.js route handler and set maxDuration explicitly. The example uses 60 seconds; the default depends on your plan and on whether Fluid Compute is enabled (300 seconds with Fluid Compute), so choose it deliberately rather than relying on the default:
// app/api/screenshot/route.ts
import chromium from '@sparticuz/chromium-min'
import puppeteer from 'puppeteer-core'
export const runtime = 'nodejs'
export const maxDuration = 60
const ALLOWED_HOSTS = new Set(['example.com'])
export async function POST(request: Request) {
const { url } = await request.json()
let target: URL
try { target = new URL(url) } catch { return new Response('Invalid URL', { status: 422 }) }
if (target.protocol !== 'https:' || !ALLOWED_HOSTS.has(target.hostname)) {
return new Response('URL not allowed', { status: 422 })
}
const browser = await puppeteer.launch({
args: chromium.args,
// CHROMIUM_PACK_URL points to the Chromium .tar you host yourself
executablePath: await chromium.executablePath(process.env.CHROMIUM_PACK_URL),
headless: 'shell',
})
try {
const page = await browser.newPage()
await page.setViewport({ width: 1440, height: 900 })
await page.goto(target.toString(), { waitUntil: 'networkidle2', timeout: 45_000 })
const png = await page.screenshot({ type: 'png' }) // viewport only, keeps the response small
return new Response(new Uint8Array(png), { headers: { 'Content-Type': 'image/png' } })
} finally {
await browser.close()
}
}Response size: Vercel Functions limit the response body to 4.5 MB, so returning a large PNG directly fails. The example above captures only the viewport; for full-page captures, upload the image to Blob or S3 and return its URL. Check Vercel's function limits for your plan, since bundle size, memory and maximum duration vary by plan and change over time. The same pattern, with different config, applies to Netlify Functions and other platforms that run on Lambda underneath. If you only need to call a screenshot service from Next.js, the Next.js Screenshot API guide shows a complete route handler without a browser.
Do not pass user URLs to a cloud browser unchecked
Both examples allow only example.com on purpose. A headless browser inside your cloud account can reach things the public internet cannot: the cloud metadata endpoint (169.254.169.254), internal services and localhost. If users can submit the URL, an attacker can ask your function to fetch those addresses and read the result as a screenshot. This is server-side request forgery (SSRF).
Allow only https:, prefer an allow-list of hosts, resolve the hostname and reject private, loopback and link-local addresses, re-check after redirects, and give the function a least-privilege IAM role. The OWASP SSRF prevention cheat sheet covers the details. SnapshotFlow also rejects private and loopback destinations, but keep your own authorization rules either way.
3. Common errors and what fixes them
| Symptom | Likely cause | Fix |
|---|---|---|
| Bundle too large to deploy | Full puppeteer or stock Chromium included | Use puppeteer-core + @sparticuz/chromium (Lambda) or chromium-min (Vercel); or a container image |
libnss3.so or other shared library missing | Chromium built for a different OS image | Use the build made for your runtime; do not mix Chromium sources |
Target closed or Connection closed | Browser crashed, usually out of memory | Raise function memory, capture one page per invocation, close the browser in finally |
ETXTBSY when launching | Chromium binary still being written to /tmp while another launch reads it | Launch the browser once per execution environment and reuse it between warm invocations |
| Navigation timeout exceeded | Slow page, heavy third-party scripts, or low CPU | Raise memory (CPU scales with it), use a looser waitUntil, set a hard timeout below the function limit |
| Empty boxes instead of text | No system fonts, especially CJK and emoji | Load a web font into the page; @sparticuz/chromium offers a font helper |
| Blank or cut-off image | Lazy loading, viewport, timing | See 9 fixes for blank Puppeteer screenshots |
The Puppeteer troubleshooting guide covers the launch-time errors in more depth.
4. Other ways to run a browser in the cloud
If the packaging workaround feels fragile, there are three other designs:
- Container image on Lambda. Container images go up to 10 GB, so you can ship a normal Chromium or a Playwright base image. You gain control, and you own image builds, patching and cold-start time.
- A long-running container service (ECS, Cloud Run, a VM). One warm browser serves many captures with no cold start. You now run and scale a browser fleet.
- Playwright instead of Puppeteer. The same
@sparticuz/chromiumapproach works withplaywright-core. Our Playwright vs Puppeteer comparison covers the API differences.
Each option keeps the browser in your infrastructure. That is the right call when you need custom browser logic or a private network. For plain "give me a screenshot of this URL", it is a lot of operations work for one image.
What running Chromium yourself costs
Beyond compute (memory × duration, plus cold starts and retries for crashed browsers), the larger cost is maintenance: Chromium and Puppeteer upgrades, font fixes and the next platform limit change. For per-shot versus per-second pricing see screenshot API pricing explained, and for the full build-versus-buy decision see Puppeteer Alternative for Screenshots.
5. The same screenshot with SnapshotFlow in one HTTP request
When the job is "capture a URL and get an image", the browser does not have to live in your function at all. Your Lambda or route handler becomes a thin HTTP client, and the bundle contains no Chromium, no layers and no font hacks. In the Lambda handler above, replace the puppeteer.launch block with:
const params = new URLSearchParams({
url: target.toString(), // the validated URL from the handler above
format: 'webp',
width: '1440',
height: '900',
full_page: 'true',
wait_until: 'networkidle2',
response_type: 'url',
})
const res = await fetch(`https://api.snapshotflow.com/screenshot?${params}`, {
headers: { 'X-Api-Key': process.env.SNAPSHOTFLOW_API_KEY },
signal: AbortSignal.timeout(45_000),
})
if (!res.ok) throw new Error(`SnapshotFlow ${res.status}: ${await res.text()}`)
const screenshotUrl = await res.text() // hosted image URL, no Chromium in your bundleThe same call works from the command line, which is handy for testing before you write any code:
curl -G "https://api.snapshotflow.com/screenshot" \
-H "X-Api-Key: $SNAPSHOTFLOW_API_KEY" \
--data-urlencode "url=https://example.com" \
-d format=webp -d full_page=true -d wait_until=networkidle2 -d response_type=urlOn Vercel the whole route handler shrinks to a validated request and a fetch. There is no serverExternalPackages, no CHROMIUM_PACK_URL and no Chromium pack to host:
// app/api/screenshot/route.ts: no Chromium, no serverExternalPackages
export const runtime = 'nodejs'
export const maxDuration = 60
const ALLOWED_HOSTS = new Set(['example.com'])
export async function POST(request: Request) {
const { url } = await request.json()
let target: URL
try { target = new URL(url) } catch { return new Response('Invalid URL', { status: 422 }) }
if (target.protocol !== 'https:' || !ALLOWED_HOSTS.has(target.hostname)) {
return new Response('URL not allowed', { status: 422 })
}
const params = new URLSearchParams({
url: target.toString(),
format: 'webp',
width: '1440',
height: '900',
full_page: 'true',
wait_until: 'networkidle2',
response_type: 'url',
})
const res = await fetch(`https://api.snapshotflow.com/screenshot?${params}`, {
headers: { 'X-Api-Key': process.env.SNAPSHOTFLOW_API_KEY ?? '' },
signal: AbortSignal.timeout(45_000),
})
if (!res.ok) return new Response('Capture failed', { status: 502 })
return Response.json({ screenshotUrl: await res.text() })
}Here is how each problem from this guide changes when the browser runs on SnapshotFlow's side:
| Problem with Puppeteer | With SnapshotFlow |
|---|---|
| 50 MB / 250 MB bundle limits, Chromium pack to host | No browser in your bundle; one fetch call |
Target closed, out-of-memory, cold start | The browser is not in your function, so there is nothing to crash or warm up |
| 6 MB (Lambda) and 4.5 MB (Vercel) response limits | response_type=url returns a short URL, not image bytes |
| Missing fonts, blank boxes | Rendering happens on SnapshotFlow's side; no fonts to bundle |
| Function timeout on slow pages | Use async jobs with signed webhooks for long captures; see async captures |
Options you would otherwise script by hand are request parameters. Each has its own guide, so this page only lists them:
- Cookie banners and ads:
block_cookie_banners,block_ads,block_trackers(cookie banner guide) - Pages behind login:
cookiesandheaders(authenticated captures) - Other countries:
countryand geolocation (geo-targeted captures) - PDF and Markdown output (webpage to PDF, URL to Markdown)
An API does not replace scripted flows with clicks and form fills, or pages that only exist inside your private network. The table below covers when to keep Puppeteer.
When to keep Puppeteer, and when to switch
| Your situation | Better fit |
|---|---|
| Scripted multi-step flows: logins, clicks, form fills, then capture | Puppeteer or Playwright in a container |
| Pages only reachable inside your private network | Self-hosted browser in that network |
| Screenshots of public or token-authenticated URLs at moderate volume | Screenshot API |
| Spiky traffic (previews on signup, monthly reports) | Screenshot API, so you do not pay for idle browsers or cold starts |
| Small team, screenshots are a side feature | Screenshot API |
You can also mix the two: keep Puppeteer for the unusual flows and send the routine captures to an API.
Try the API route from your function
- Create a SnapshotFlow account and copy an API key from the dashboard. New accounts include free captures, no card required.
- Store the key as
SNAPSHOTFLOW_API_KEYin your Lambda or Vercel environment variables, never in client code. - Replace the
puppeteer.launchblock with thefetchcall above and removepuppeteer-coreand@sparticuz/chromiumfrompackage.json. - Redeploy, and compare the bundle size and the first-request time before and after.
Sources and further reading
Skip the Chromium packaging
Send the URL, get the image. No layers, no cold-start tuning, no fonts to bundle.
Get a free API key Read the API docs