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 imagepublic.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) thatexecutablePath()unpacks into/tmpon 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-processand--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 guides
- Puppeteer screenshot recipes, tested on Puppeteer 25
- Puppeteer in Docker, for a long-running service instead of a function
- Puppeteer timeouts and memory leaks
- Serverless Chromium, explained
Related in this topic
- Puppeteer Screenshots: Tested Recipes for Puppeteer 25 Every Puppeteer screenshot recipe re-run on Puppeteer 25.14 and Chrome 155: viewport, full page, element, clip, waits, formats, auth, blocking and fonts.
- Puppeteer in Docker: A Tested Dockerfile with Fonts A non-root Puppeteer Dockerfile with Chromium, CJK and emoji fonts and dumb-init, plus the sandbox error it hits, measured image sizes and launch times.
- Puppeteer Full Page Screenshot: Limits, Lazy Images, Fixes Puppeteer full page screenshot guide: the 16,384 px limit we measured, captureBeyondViewport, lazy-load scrolling, sticky headers and a tested script.
- Hide Cookie Banners and Chat Widgets in Puppeteer Remove cookie banners, consent iframes, shadow-DOM dialogs and chat widgets from Puppeteer screenshots: a tested module and what each method misses.
- Puppeteer HTML to PDF: page.pdf() Options Tested Generate PDFs with Puppeteer page.pdf(): paper size, margins, header and footer templates, page breaks and print CSS, with measured sizes and timings.
- Puppeteer Screenshot Timeouts and Memory Leaks: A Debugging Guide A comprehensive debugging guide for Puppeteer screenshot issues — covering navigation timeouts, memory leaks, zombie Chrome processes, and production error handling patterns.