How To Use Puppeteer on AWS Lambda for Headless Automation

Puppeteer can run on AWS Lambda, but it needs a Chromium build made for Lambda’s Linux environment. In this guide, we will build a Lambda function that accepts an approved URL, opens it in headless Chromium, captures a screenshot, and returns the image.
If you only need the essential setup, start with the TL;DR below. If you are implementing this for production, follow the numbered steps; the packaging, URL validation, memory, and response-size details are where most deployments go wrong.
- Use the version-matched pair
puppeteer-core@25.1.0 and @sparticuz/chromium@149.0.0; do not rely on Puppeteer downloading a desktop browser during deployment.- Use Node.js 24.x and x86_64 for the full
@sparticuz/chromium npm package.- Start with 2,048 MB of memory and a 30-second timeout.
- Launch the browser with
chromium.args and await chromium.executablePath().- Package the function and dependencies together, or use
chromium-min with a Chromium layer when several functions need the same binary.- Validate every URL before navigation. An unrestricted screenshot endpoint can become an SSRF vulnerability.
- Return small screenshots directly; store larger screenshots and PDFs in S3.
- Close the browser in a
finally block, even when navigation or rendering fails.The minimum package and launch setup is:
npm install --save-exact puppeteer-core@25.1.0 @sparticuz/chromium@149.0.0import puppeteer from 'puppeteer-core';
import chromium from '@sparticuz/chromium';
const browser = await puppeteer.launch({
args: await puppeteer.defaultArgs({
args: chromium.args,
headless: 'shell',
}),
executablePath: await chromium.executablePath(),
headless: 'shell',
});That is the core integration. We will now turn it into a deployable function.
What We Will Build
By the end of the guide, our Lambda function will:
- receive a trusted HTTPS URL;
- reject URLs outside an approved hostname list;
- start a Lambda-compatible headless Chromium binary;
- wait for the page to render;
- capture a full-page PNG screenshot;
- return the screenshot as a Base64 response;
- close Chromium whether the job succeeds or fails.
The same setup can generate PDFs or extract rendered page data. We will add the PDF variation after the screenshot flow works.
Before You Start
You will need:
- an AWS account with permission to create and update Lambda functions;
- Node.js and npm installed locally;
- the AWS CLI if you plan to deploy from the command line;
- a Linux-compatible build environment for ZIP packaging;
- one or more domains that the function is allowed to open.
Use these Lambda settings as the starting point:
| Setting | Recommended starting point |
| Runtime | Node.js 24.x |
| Architecture | x86_64 when using the full @sparticuz/chromium npm package |
| Memory | 2,048 MB |
| Timeout | 30–60 seconds |
| Ephemeral storage | 512 MB initially; increase for large downloads or generated files |
| Packages | puppeteer-core@25.1.0 and @sparticuz/chromium@149.0.0 |
AWS currently supports Node.js 24 on the Amazon Linux 2023 Lambda runtime. The Chromium project recommends at least 512 MB of memory and 1,600 MB or more for practical use. Starting at 2,048 MB is sensible because Lambda also allocates CPU in proportion to memory.
If the function is still timing out, do not increase the timeout blindly. Check whether Chromium is starting slowly, navigation is waiting on a page that never becomes idle, or the target site is blocking the request.
Choose a Deployment Method
There are three workable deployment routes. We will use the first one for the main walkthrough.
| Method | Choose it when | What gets deployed |
| ZIP package | We want the simplest first deployment | Function code, puppeteer-core, and full @sparticuz/chromium package |
| Lambda layer | Several functions share the Chromium binary | Function code plus puppeteer-core and chromium-min; Chromium files in a layer |
| Container image | We need more dependencies, fonts, or package space | The complete function environment as an image |
If this is your first Puppeteer Lambda, begin with the ZIP route. Once the handler works, moving Chromium into a layer or container is much easier to reason about.
How Puppeteer and Lambda Fit Together
Puppeteer is a Node.js library that controls Chrome or Chromium through the Chrome DevTools Protocol. It can render JavaScript, interact with elements, wait for client-side navigation, capture screenshots, and generate PDFs.
puppeteer-core provides that automation API without downloading a browser. @sparticuz/chromium supplies a headless Chromium binary and launch arguments designed for serverless Linux environments. Lambda provides the short-lived compute that runs both.
This combination works best for short, event-driven jobs such as screenshot APIs, scheduled page checks, PDF generation, and bounded scraping tasks. It is less suitable for a browser that must stay alive or a crawl that runs for hours.
Step 1: Create the Lambda Project
mkdir puppeteer-lambda
cd puppeteer-lambda
npm init -y
npm install --save-exact puppeteer-core@25.1.0 @sparticuz/chromium@149.0.0Add "type": "module" to package.json so Node.js can use the ES module imports shown in this guide:
{
"name": "puppeteer-lambda",
"version": "1.0.0",
"type": "module",
"dependencies": {
"@sparticuz/chromium": "149.0.0",
"puppeteer-core": "25.1.0"
}
}Puppeteer releases are tied to particular Chrome versions, while the major version of @sparticuz/chromium identifies its Chromium version. Puppeteer 25.1.0 supports Chrome 149.0.7827.22, which matches the Chromium build in Sparticuz 149.0.0. Commit the lockfile and keep the versions exact so an untested browser upgrade does not enter production during deployment.
Checkpoint: The project should now contain package.json, package-lock.json, and a node_modules directory with both packages installed.
Step 2: Add a Working Screenshot Handler
Create index.mjs:
import puppeteer from 'puppeteer-core';
import chromium from '@sparticuz/chromium';
const ALLOWED_HOSTS = new Set([
'example.com',
'www.example.com',
]);
function getAllowedUrl(requestedUrl = 'https://example.com') {
const url = new URL(requestedUrl);
const hasUnexpectedPort = url.port && url.port !== '443';
if (
url.protocol !== 'https:' ||
!ALLOWED_HOSTS.has(url.hostname) ||
url.username ||
url.password ||
hasUnexpectedPort
) {
throw new Error('URL is not allowed');
}
return url.toString();
}
export const handler = async event => {
let browser;
try {
const url = getAllowedUrl(event?.url);
browser = await puppeteer.launch({
args: await puppeteer.defaultArgs({
args: chromium.args,
headless: 'shell',
}),
defaultViewport: {
width: 1440,
height: 900,
deviceScaleFactor: 1,
},
executablePath: await chromium.executablePath(),
headless: 'shell',
});
const page = await browser.newPage();
page.setDefaultNavigationTimeout(25_000);
await page.goto(url, {
waitUntil: 'domcontentloaded',
});
// Check the final page after redirects as well.
getAllowedUrl(page.url());
await page
.waitForNetworkIdle({idleTime: 500, timeout: 5_000})
.catch(() => undefined);
const screenshot = await page.screenshot({
type: 'png',
fullPage: true,
encoding: 'base64',
});
return {
statusCode: 200,
headers: {
'Content-Type': 'image/png',
'Cache-Control': 'no-store',
},
body: screenshot,
isBase64Encoded: true,
};
} catch (error) {
console.error('Screenshot failed', {
name: error?.name,
message: error?.message,
});
return {
statusCode: 500,
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({error: 'Screenshot generation failed'}),
};
} finally {
if (browser) {
await browser.close().catch(closeError => {
console.error('Browser close failed', closeError);
});
}
}
};Before continuing, replace the example hostname in ALLOWED_HOSTS with a domain the function genuinely needs to open. Do not remove the validation merely to make testing easier; an unrestricted URL parameter changes the security profile of the entire function.
Checkpoint: index.mjs should export a function named handler, and every hostname used in the test should appear in ALLOWED_HOSTS.
Step 3: Package and Deploy the Function
For a ZIP-based deployment, include index.mjs, package.json, the lockfile, and production dependencies:
npm ci --omit=dev
zip -r function.zip index.mjs package.json package-lock.json node_modulesBuild the package in a Linux-compatible environment and deploy it to an x86_64 Lambda function. AWS allows direct ZIP uploads up to 50 MB; larger ZIP archives must be uploaded through S3. The combined unzipped function and layer contents still cannot exceed 250 MB.
Partner with Us for Success
Experience seamless collaboration and exceptional results.
In the Lambda console, configure:
- Runtime: Node.js 24.x
- Architecture: x86_64
- Handler:
index.handler - Memory: 2,048 MB
- Timeout: 30 seconds
Checkpoint: The deployed Lambda configuration should show Node.js 24.x, x86_64, 2,048 MB, a 30-second timeout, and index.handler as the handler.
Step 4: Test the Screenshot Function
Create a Lambda test event:
{
"url": "https://example.com"
}Run the test. A successful invocation should return:
statusCode: 200;Content-Type: image/png;isBase64Encoded: true;- a long Base64 string in
body.
Seeing unreadable Base64 in the Lambda console is normal—it represents the PNG bytes. To verify the image through API Gateway or a Lambda Function URL, configure the integration to pass the binary response correctly.
If the invocation fails, start with the CloudWatch log entry written by Screenshot failed. The error name and message usually identify whether the problem occurred during Chromium launch, navigation, or response generation.
Checkpoint: We now have the complete working path: event → validated URL → Chromium → rendered page → PNG response → browser cleanup.
Optional: Generate a PDF Instead of a Screenshot
The browser launch code stays the same. Replace the screenshot operation with page.pdf():
const pdf = await page.pdf({
format: 'A4',
printBackground: true,
margin: {
top: '16mm',
right: '14mm',
bottom: '16mm',
left: '14mm',
},
});
return {
statusCode: 200,
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': 'inline; filename="page.pdf"',
},
body: Buffer.from(pdf).toString('base64'),
isBase64Encoded: true,
};This is fine for small PDFs. For larger documents, save the PDF to S3 and return an object key or a short-lived presigned URL. Lambda’s standard synchronous response payload is limited to 6 MB, and Base64 makes the response larger.
PDF output can also differ from a developer laptop because Lambda does not provide a normal desktop font collection. @sparticuz/chromium includes Open Sans for Latin, Greek, and Cyrillic text. Add the exact fonts the document requires, especially for Indian-language, CJK, Arabic, or branded PDF output.
Using a Lambda Layer for Chromium
Layers are useful when several functions share the same Chromium build. They improve dependency organization and let us update the browser separately from function code.
But here is the detail that is easy to miss: a layer does not bypass the 250 MB unzipped quota. AWS calculates that limit across the function and every attached layer.
Instead of relying on a copied public ARN, publish an immutable Chromium layer in the AWS account and Region where the function runs. The official Sparticuz repository provides architecture-specific layer artifacts in its releases and commands for building a layer:
archType="x64"
git clone --depth=1 --branch v149.0.0 https://github.com/Sparticuz/chromium.git
cd chromium
make chromium.${archType}.zipUpload the generated artifact to S3 and publish it as a layer version. Keep puppeteer-core and @sparticuz/chromium-min with the function:
npm install --save-exact puppeteer-core@25.1.0 @sparticuz/chromium-min@149.0.0Then point the minimal package to the Brotli files mounted by the layer:
import puppeteer from 'puppeteer-core';
import chromium from '@sparticuz/chromium-min';
const browser = await puppeteer.launch({
args: await puppeteer.defaultArgs({
args: chromium.args,
headless: 'shell',
}),
executablePath: await chromium.executablePath('/opt/chromium'),
headless: 'shell',
});Layer ARNs are Region-, account-, architecture-, and version-specific. Publishing our own layer also makes the deployed binary easier to audit and reproduce.
Deploying Puppeteer with a Lambda Container Image
A container image is often simpler when the ZIP package approaches Lambda’s size quota, the function needs extra fonts, or the deployment already uses Docker. Lambda accepts container images up to 10 GB uncompressed.
We can still use the tested Sparticuz binary inside the image:
FROM public.ecr.aws/lambda/nodejs:24
COPY package.json package-lock.json ${LAMBDA_TASK_ROOT}/
RUN npm ci --omit=dev
COPY index.mjs ${LAMBDA_TASK_ROOT}/
CMD ["index.handler"]This avoids inventing a system Chromium path that may not exist in the base image. If we deliberately install a distribution-provided Chromium build instead, its system libraries, executable location, architecture, and Puppeteer compatibility all need to be verified in the final image.
Choosing the Right Page Load Strategy
waitUntil: 'networkidle0' is not automatically the “most complete” option. Analytics, chat widgets, streaming connections, and polling can keep a page active until the Lambda times out.
Use the condition that matches the job:
| Strategy | Good fit |
domcontentloaded | Fast metadata extraction or pages with a known selector |
load | Pages that finish useful work with the browser load event |
networkidle2 | Pages that settle after most requests complete |
page.waitForSelector() | The job depends on one specific rendered element |
Bounded waitForNetworkIdle() | A screenshot needs a short settling period |
When possible, wait for a meaningful selector rather than guessing that the entire internet has gone quiet:
await page.goto(url, {
waitUntil: 'domcontentloaded',
timeout: 25_000,
});
await page.waitForSelector('[data-page-ready="true"]', {
timeout: 8_000,
});This tends to be both faster and more reliable.
Security: Do Not Turn the Function into an Open Browser Proxy
If an API accepts any caller-supplied URL, an attacker may use Chromium to request internal services, cloud metadata endpoints, private VPC addresses, or very large resources. This is a server-side request forgery risk.
For a screenshot or scraping API:
- allow only
https:URLs; - use an exact hostname allowlist where the product permits it;
- reject embedded credentials and unexpected ports;
- do not assume checking only the first URL makes redirects safe;
- restrict outbound networking with a proxy or network policy when arbitrary destinations are unavoidable;
- block private, loopback, link-local, and metadata address ranges;
- authenticate the endpoint and apply request and concurrency limits;
- cap navigation time, output dimensions, and expected response size;
- avoid logging cookies, authorization headers, page contents, or signed URLs.
A hostname check is suitable when we control a small list of destinations. It is not a complete network sandbox when users can supply arbitrary domains, because DNS and redirect behavior must also be controlled.
Scraping also needs a non-technical check: confirm that the automation is permitted, respect access controls and reasonable rate limits, and do not use Puppeteer to bypass authentication or anti-bot protections.
Performance and Reliability Best Practices
Set Reserved Concurrency
Lambda can start many execution environments during a burst, and each one may launch Chromium. Reserved concurrency places an upper bound on browser processes, cost, and traffic sent to the target website.
Partner with Us for Success
Experience seamless collaboration and exceptional results.
Keep Work Within the Invocation
Always close the browser in a finally block. A warm execution environment may be reused, but we should not depend on it being available. Caching a browser outside the handler can reduce startup time, although it also requires careful context, cookie, page, crash, and cleanup handling.
Use /tmp Deliberately
Chromium extracts files into Lambda’s temporary storage. Warm invocations may reuse that storage, but Lambda can recycle it at any time. Treat /tmp as a cache, never as durable storage, and increase ephemeral storage when the job downloads or generates large files.
Block Unnecessary Resources
For text extraction, images, fonts, media, and analytics may be unnecessary. Request interception can reduce navigation time and bandwidth. Do not block a resource type without testing, because some sites use fonts, stylesheets, or image requests as part of their rendering logic.
Send Large Results to S3
Returning large screenshots or PDFs through Lambda adds Base64 overhead and can exceed payload limits. S3 is the better handoff point for large or reusable artifacts, with lifecycle rules for automatic cleanup.
Measure Before Adding Provisioned Concurrency
Provisioned concurrency can reduce Lambda cold starts, but Chromium still needs to launch inside the invocation unless the application safely reuses a warm browser. Measure initialization and launch time separately before paying for always-ready Lambda environments.
Common Puppeteer AWS Lambda Errors
| Error or symptom | Likely cause | Fix |
Failed to launch the browser process | Insufficient memory, incompatible binary, wrong architecture, or invalid launch arguments | Start with 2,048 MB, verify x86_64 versus arm64, and use the documented Chromium arguments and path |
The input directory "/var/task/bin" does not exist | A bundler moved or inlined files used by @sparticuz/chromium | Mark @sparticuz/chromium as an external dependency |
spawn ... ENOENT | The executable path or layer contents are missing | Use chromium.executablePath(), or verify the layer mount and chromium-min pack path |
| Function times out | Default timeout is too short, navigation never settles, or the site is slow | Set 30–60 seconds and wait for a meaningful selector with a bounded timeout |
Process exits with signal: killed | Chromium exhausted available memory | Increase memory, reduce page size and concurrency, and close unused pages |
| Screenshot is blank or incomplete | The page was captured before client rendering finished | Wait for the required selector, fonts, or a bounded network-idle period |
| PDF uses the wrong font | The required font is unavailable in Lambda | Package the exact font in the function or a font layer |
| Response is rejected or truncated | Base64 output exceeds the integration’s payload limit | Upload the file to S3 and return a reference |
| Works locally but fails on Lambda | Local Chrome, operating system, or architecture differs | Test in an Amazon Linux-compatible container with the deployed package |
When Puppeteer on Lambda Is the Wrong Choice
Lambda is a strong fit for short, independent browser jobs. It becomes a poor fit when:
- a crawl may run longer than 15 minutes;
- a browser session must remain alive between jobs;
- hundreds of pages must be processed in one invocation;
- large video or browser-profile data must persist;
- stable residential or dedicated outbound networking is required;
- browser reuse is essential for cost efficiency.
For sustained crawls or persistent sessions, ECS Fargate, AWS Batch, or a controlled container service is usually easier to operate. We can still use SQS to divide a large crawl into short Lambda jobs, but each message should represent a bounded, retryable unit of work.
Frequently Asked Questions
Why use puppeteer-core instead of puppeteer on Lambda?
puppeteer-core provides the automation API without downloading a browser. This lets us supply a Lambda-compatible Chromium build explicitly, control package size, and pin the browser version used in production.
Does a Lambda layer remove the 250 MB package limit?
No. Layers separate and reuse dependencies, but AWS counts the unzipped function, custom runtime, and all attached layers together. Their combined contents must remain within the 250 MB ZIP-deployment quota.
How much memory does Puppeteer need on Lambda?
The Chromium package recommends 1,600 MB or more for practical use. Starting with 2,048 MB gives Chromium more memory and CPU, after which AWS Lambda Power Tuning can identify a better cost-performance point.
Can Puppeteer on Lambda handle concurrent requests?
Yes. Lambda creates additional execution environments as concurrency rises, and each active environment can run a browser. Set reserved concurrency so a sudden burst does not overwhelm costs or the destination website.
Should we return screenshots and PDFs as Base64?
Base64 is convenient for small synchronous responses. For larger artifacts, upload the bytes to S3 and return an object reference or presigned URL to avoid response limits and unnecessary encoding overhead.
Can Puppeteer run on ARM-based Lambda functions?
Yes, but the full @sparticuz/chromium npm package contains x64 binaries. For arm64, use @sparticuz/chromium-min with an arm64 layer artifact or remote pack from a compatible release.
Is Puppeteer on Lambda suitable for long-running web scraping?
Not for a single sustained crawl. Lambda stops an invocation after 15 minutes. Split bounded pages into queued jobs or use Fargate, Batch, or another container service for persistent browser workloads.
Conclusion
The reliable Puppeteer-on-Lambda setup is straightforward once the responsibilities are clear: puppeteer-core controls the browser, @sparticuz/chromium provides a compatible Chromium binary, and Lambda supplies the short-lived compute environment.
Start with Node.js 24, x86_64, 2,048 MB of memory, a 30-second timeout, and the package’s documented launch arguments. Then validate URLs, use bounded page waits, close the browser, and move large output to S3.
Those choices solve more than the first deployment. They also make the function safer under public input, more predictable under concurrency, and easier to debug when a target page or browser version changes.



