· Tutorials · By Simon Chiu

Puppeteer on AWS Lambda with @sparticuz/chromium

Run Puppeteer on AWS Lambda with @sparticuz/chromium: version matching, a container-image handler tested in the Lambda emulator, and warm reuse.

To run Puppeteer on AWS Lambda, use puppeteer-core with @sparticuz/chromium, a Chromium build packed for Lambda's Amazon Linux 2023 runtime. The Playwright version of this setup uses the same package. Match the two packages' Chrome major version, launch with the package's arguments and headless: 'shell', and keep the browser alive between invocations. This handler ran in the official Lambda Node.js 22 base image:

// index.mjs
import chromium from '@sparticuz/chromium';
import puppeteer from 'puppeteer-core';

chromium.setGraphicsMode = false; // no WebGL needed for page screenshots

// Reused across warm invocations of the same execution environment.
let browserPromise = null;

async function getBrowser() {
  if (browserPromise) {
    const b = await browserPromise.catch(() => null);
    if (b?.connected) return b;
  }
  browserPromise = puppeteer.launch({
    args: await puppeteer.defaultArgs({ args: chromium.args, headless: 'shell' }),
    executablePath: await chromium.executablePath(),
    headless: 'shell',
    defaultViewport: { width: 1280, height: 800 },
  });
  return browserPromise;
}

export const handler = async (event) => {
  const url = event.url ?? 'https://example.com';
  if (!/^https?:\/\//.test(url)) return { statusCode: 400, body: 'url must be http(s)' };

  const t0 = Date.now();
  const browser = await getBrowser();
  const tBrowser = Date.now();
  // chromium.args includes --single-process. In our test, a page in a second
  // browser context crashed the browser, so use plain pages and close them.
  const page = await browser.newPage();
  try {
    await page.goto(url, { waitUntil: 'load', timeout: 15_000 });
    const png = await page.screenshot({ type: 'png' });
    console.log(JSON.stringify({ url, browserMs: tBrowser - t0, captureMs: Date.now() - tBrowser, bytes: png.length }));
    return {
      statusCode: 200,
      headers: { 'content-type': 'image/png' },
      isBase64Encoded: true,
      body: Buffer.from(png).toString('base64'),
    };
  } finally {
    await page.close();
  }
};

The rest of this page explains each choice, the two errors we hit before it worked, and how to measure cold starts and memory on your own function. For the screenshot options themselves (viewport, waits, formats), see the Puppeteer screenshot recipes.

Tested on 2026-10-18 with [email protected] and @sparticuz/[email protected] (HeadlessChrome/153.0.8010.0), in the image public.ecr.aws/lambda/nodejs:22, invoked through the Lambda runtime interface emulator that the image includes. The host was an Apple M1 Pro running Docker 29.8.2 (OrbStack), so the x86_64 image ran under emulation. We did not deploy to AWS for this page. The handler, the errors and the fixes are real; the timings below are emulator timings and are not Lambda numbers. The last section shows how to get real ones.

Why not the normal puppeteer package

puppeteer downloads Chrome for Testing, which expects a full Linux desktop's shared libraries and writes to the home directory. Lambda gives you a read-only file system except /tmp, no fonts, and none of those libraries. @sparticuz/chromium solves this in three parts:

  • a Brotli-compressed Chromium (bin/chromium.br, 64 MB in version 153.0.0) that executablePath() unpacks into /tmp on the first call;
  • the Amazon Linux 2023 libraries Chromium needs (al2023.tar.br) and one font, Open Sans, for Latin, Greek and Cyrillic;
  • an argument list tuned for Lambda, which includes --no-sandbox, --no-zygote, --single-process and --in-process-gpu.

It is built on headless_shell, the old headless mode, which is why the launch passes headless: 'shell'. The chrome-headless-shell glossary entry explains the difference.

Step 1: match the versions

@sparticuz/chromium versions follow Chromium's major version (153.0.0 is Chromium 153). puppeteer-core expects one specific Chrome. If they drift apart, CDP calls can fail in ways that look random. We looked up the expected Chrome in lib/puppeteer/revisions.js of each recent puppeteer-core release:

puppeteer-core Expects Chrome Pair with
25.10.0 152.0.7977.75 @sparticuz/chromium@152
25.11.0 153.0.8010.36 @sparticuz/chromium@153
25.12.0 154.0.8037.57 @sparticuz/chromium@154
25.13.0 155.0.8059.39 @sparticuz/chromium@155
25.14.0 155.0.8059.79 @sparticuz/chromium@155

On the day we tested, the newest @sparticuz/chromium on npm was 153.0.0, so we used [email protected]. Pin both exactly:

{
  "name": "shot-lambda",
  "private": true,
  "type": "module",
  "dependencies": {
    "@sparticuz/chromium": "153.0.0",
    "puppeteer-core": "25.11.0"
  }
}

The package's README warns that it does not follow semantic versioning, and breaking changes can land in a patch release. Pin, and read the release notes before you bump.

Step 2: choose how to package it

Lambda limits decide this, and Chromium is close to them. A zip uploaded directly is limited to 50 MB, and the function plus its layers to 250 MB unzipped. chromium.br alone is 64 MB.

Option How Good for
Container image FROM public.ecr.aws/lambda/nodejs:22, npm install inside The simplest path; up to 10 GB; what we tested
Zip through S3 Bundle node_modules, upload the zip to S3, point the function at it Teams already on zip deploys
Layer Put @sparticuz/chromium in a layer, keep the function code small Several functions sharing one Chromium
@sparticuz/chromium-min Host the pack.tar on HTTPS, pass its URL to executablePath() Small deploys; the first cold start downloads Chromium

The container image we tested:

FROM public.ecr.aws/lambda/nodejs:22
COPY package.json ${LAMBDA_TASK_ROOT}/
RUN cd ${LAMBDA_TASK_ROOT} && npm install --omit=dev
COPY index.mjs ${LAMBDA_TASK_ROOT}/
CMD ["index.handler"]

Build it for x86_64, because the npm package contains only x64 binaries:

docker build --platform linux/amd64 -t shot-lambda .

For Graviton (arm64), the package README points to @sparticuz/chromium-min with the arm64 layer zip or pack tar from the project's GitHub releases. We did not test arm64.

Step 3: test it locally the way Lambda runs it

The Lambda base images include the runtime interface emulator. Start the container and post an event to it:

docker run --rm --platform linux/amd64 -p 9000:8080 shot-lambda

curl -s -XPOST \
  "http://localhost:9000/2015-03-31/functions/function/invocations" \
  -d '{"url":"https://example.com"}' > resp.json

The response is the API Gateway–style object; body is the base64 PNG. The container's log shows the REPORT line for each invocation, in the same format as CloudWatch.

Error 1: libnspr4.so: cannot open shared object file

Our first debugging run used --entrypoint node to run a test script in the same image. Chromium failed to start:

/tmp/chromium: error while loading shared libraries: libnspr4.so:
cannot open shared object file: No such file or directory

@sparticuz/chromium unpacks its Amazon Linux libraries and sets the library path only when it detects a Lambda environment. Outside the runtime, it did not. Setting AWS_EXECUTION_ENV=AWS_Lambda_nodejs22.x and AWS_LAMBDA_FUNCTION_NAME=shot made the same script work. Through the emulator, the variables are already set.

Error 2: Target closed with a browser context per request

Our first handler isolated each request in its own browser context, the pattern we use on servers. Every invocation failed:

ConnectionClosedError: Connection closed.

The real error was hidden by the finally block, which tried to close a context on a browser that was already gone. Testing the steps one by one showed it:

page newPage ok
page shot 44728
page closed true
context ERR Protocol error (Target.createTarget): Target closed

Pages in the default context worked. Creating a page in a new context killed the browser. The likely cause is --single-process in chromium.args, which runs everything in one process. We changed the handler to use browser.newPage() and close the page after each request. If you need clean cookies per request, clear them yourself with browser.deleteCookie(), or accept one invocation per browser for those jobs.

Step 4: reuse the browser across warm invocations

Lambda keeps the execution environment, and the module scope, alive between invocations for a while. getBrowser() stores the launch promise in module scope, checks browser.connected before it reuses it, and launches again if Chrome died.

Five invocations through the emulator, under x86_64 emulation on our Mac:

Invocation browserMs captureMs Reported duration
1 (cold) 2,706 2,224 6,746 ms
2 0 181 197 ms
3 0 164 187 ms
4 0 157 166 ms
5 0 128 138 ms

The emulator also reported an init phase of 1,684 ms before the first invocation. Do not read these as Lambda timings; emulated x86 on Apple silicon is slower than a native Lambda CPU, and the emulator does not enforce the memory setting. What the table does show is the shape: almost all of the cold cost is unpacking and launching Chromium, and a warm invocation that reuses the browser pays only for the page.

The PNG of example.com was 60,898 bytes here, against 73,698 bytes from Debian's Chromium in Docker with Noto fonts. The difference is mostly fonts: Lambda has only Open Sans unless you add more.

Step 5: measure cold starts, memory and cost on real Lambda

After you deploy, the numbers you need are in the REPORT lines. This CloudWatch Logs Insights query splits cold and warm invocations:

filter @type = "REPORT"
| fields ispresent(@initDuration) as cold
| stats count(*) as invocations,
        avg(@initDuration) as avg_init_ms,
        pct(@duration, 50) as p50_ms,
        pct(@duration, 95) as p95_ms,
        max(@maxMemoryUsed) / 1000 / 1000 as max_mem_mb
  by cold

Then repeat with two or three memory sizes. Lambda gives CPU in proportion to memory, so a larger setting can finish faster. The cost per screenshot is:

cost = (memory_GB × billed_duration_s × price_per_GB_second) + price_per_request

Take both prices from the AWS Lambda pricing page for your region and architecture on the day you calculate. Compare memory sizes on cost per 1,000 screenshots, not on duration alone.

Fonts on Lambda

Lambda has no system fonts, and @sparticuz/chromium adds only Open Sans. CJK text and emoji render as boxes until you add fonts. The package's fontconfig looks in /var/task/fonts and /var/task/.fonts, among others. In a container image, copy the files there:

COPY fonts/ ${LAMBDA_TASK_ROOT}/fonts/

Use font files you have a license to ship, for example the Noto families. Each CJK font adds tens of megabytes, which matters for zip deploys and not much for container images. The Docker guide shows the same problem and fix on Debian.

Limits to design around

  • Response size. A synchronous invocation response is limited to 6 MB, and base64 adds a third. A full-page PNG can exceed it. Write large images to S3 and return a URL instead.
  • /tmp. Chromium unpacks into /tmp, which has 512 MB by default. Screenshots you write there count against it, and a warm environment keeps them.
  • Timeout. Set the function timeout above your navigation timeout plus the cold start, and keep the navigation timeout well under it, so the function returns an error instead of being killed.
  • Concurrency. Each concurrent request gets its own environment and its own Chromium. That scales without a pool, and every new environment pays a cold start.

Troubleshooting

Symptom Cause Fix
libnspr4.so: cannot open shared object file Running outside Lambda; libraries not unpacked Run through the emulator, or set AWS_EXECUTION_ENV and AWS_LAMBDA_FUNCTION_NAME
Protocol error (Target.createTarget): Target closed New browser context under --single-process Use browser.newPage() in the default context
ConnectionClosedError: Connection closed. in finally Browser already crashed; the original error is hidden Log the error before cleanup; wrap cleanup in its own try
Random CDP errors after an upgrade puppeteer-core and @sparticuz/chromium majors differ Match the versions (Step 1)
exec format error or Chromium fails on arm64 npm package is x64 only Deploy x86_64, or use chromium-min with the arm64 pack
Zip upload rejected Over the 50 MB direct upload limit Upload through S3, use a layer, or a container image
Boxes instead of CJK text or emoji Only Open Sans is available Add fonts in /var/task/fonts
Task timed out Function timeout lower than cold start plus navigation Raise the function timeout; lower goto timeout
Function URL returns an error for large pages Response over 6 MB Store in S3 and return the URL

The same capture without a Lambda

A Lambda function removes servers, not the browser work: version pairing, fonts, cold starts and the 6 MB response limit stay yours. RenderScreenshot runs the browser and stores the image on a CDN, so your Lambda (or any code) makes one HTTP call. With the Node.js SDK:

import { Client, TakeOptions } from 'renderscreenshot';

const client = new Client(process.env.RS_API_KEY);

export const handler = async (event) => {
  const result = await client.takeJson(
    TakeOptions.url(event.url ?? 'https://example.com')
      .width(1280)
      .height(800)
      .format('png'),
  );
  return { statusCode: 200, body: JSON.stringify({ url: result.image?.url }) };
};

takeJson() returns the CDN URL instead of the bytes, so the response stays small for any page length. The background jobs guide covers queues and retries, and error codes lists what can come back. Get a free API key with 150 credits a month.

Related in this topic