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 .so files such as libnss3.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.0

A 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.png

Deployment 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.0

Upload 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 config

serverExternalPackages 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

Typical Puppeteer failures on serverless and the first thing to check.
SymptomLikely causeFix
Bundle too large to deployFull puppeteer or stock Chromium includedUse puppeteer-core + @sparticuz/chromium (Lambda) or chromium-min (Vercel); or a container image
libnss3.so or other shared library missingChromium built for a different OS imageUse the build made for your runtime; do not mix Chromium sources
Target closed or Connection closedBrowser crashed, usually out of memoryRaise function memory, capture one page per invocation, close the browser in finally
ETXTBSY when launchingChromium binary still being written to /tmp while another launch reads itLaunch the browser once per execution environment and reuse it between warm invocations
Navigation timeout exceededSlow page, heavy third-party scripts, or low CPURaise memory (CPU scales with it), use a looser waitUntil, set a hard timeout below the function limit
Empty boxes instead of textNo system fonts, especially CJK and emojiLoad a web font into the page; @sparticuz/chromium offers a font helper
Blank or cut-off imageLazy loading, viewport, timingSee 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/chromium approach works with playwright-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 bundle

The 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=url

On 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:

Serverless Puppeteer problems and what happens with a screenshot API.
Problem with PuppeteerWith SnapshotFlow
50 MB / 250 MB bundle limits, Chromium pack to hostNo browser in your bundle; one fetch call
Target closed, out-of-memory, cold startThe browser is not in your function, so there is nothing to crash or warm up
6 MB (Lambda) and 4.5 MB (Vercel) response limitsresponse_type=url returns a short URL, not image bytes
Missing fonts, blank boxesRendering happens on SnapshotFlow's side; no fonts to bundle
Function timeout on slow pagesUse 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:

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

Pick by what the job needs, not by habit.
Your situationBetter fit
Scripted multi-step flows: logins, clicks, form fills, then capturePuppeteer or Playwright in a container
Pages only reachable inside your private networkSelf-hosted browser in that network
Screenshots of public or token-authenticated URLs at moderate volumeScreenshot 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 featureScreenshot 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

  1. Create a SnapshotFlow account and copy an API key from the dashboard. New accounts include free captures, no card required.
  2. Store the key as SNAPSHOTFLOW_API_KEY in your Lambda or Vercel environment variables, never in client code.
  3. Replace the puppeteer.launch block with the fetch call above and remove puppeteer-core and @sparticuz/chromium from package.json.
  4. 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