Blogs/Technology

How To Use Puppeteer on AWS Lambda for Headless Automation

Written byGoutham
Aug 18, 2026
12 Min Read
How To Use Puppeteer on AWS Lambda for Headless Automation Hero

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.

Too Long? Read This First

- 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.0
import 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:

  1. receive a trusted HTTPS URL;
  2. reject URLs outside an approved hostname list;
  3. start a Lambda-compatible headless Chromium binary;
  4. wait for the page to render;
  5. capture a full-page PNG screenshot;
  6. return the screenshot as a Base64 response;
  7. 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:

SettingRecommended starting point
RuntimeNode.js 24.x
Architecturex86_64 when using the full @sparticuz/chromium npm package
Memory2,048 MB
Timeout30–60 seconds
Ephemeral storage512 MB initially; increase for large downloads or generated files
Packagespuppeteer-core@25.1.0 and @sparticuz/chromium@149.0.0
Runtime
Recommended starting point
Node.js 24.x
1 of 6

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.

MethodChoose it whenWhat gets deployed
ZIP packageWe want the simplest first deploymentFunction code, puppeteer-core, and full @sparticuz/chromium package
Lambda layerSeveral functions share the Chromium binaryFunction code plus puppeteer-core and chromium-min; Chromium files in a layer
Container imageWe need more dependencies, fonts, or package spaceThe complete function environment as an image
ZIP package
Choose it when
We want the simplest first deployment
What gets deployed
Function code, puppeteer-core, and full @sparticuz/chromium package
1 of 3

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

Add "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_modules

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

  1. Runtime: Node.js 24.x
  2. Architecture: x86_64
  3. Handler: index.handler
  4. Memory: 2,048 MB
  5. 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}.zip

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

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

StrategyGood fit
domcontentloadedFast metadata extraction or pages with a known selector
loadPages that finish useful work with the browser load event
networkidle2Pages 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
domcontentloaded
Good fit
Fast metadata extraction or pages with a known selector
1 of 5

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 symptomLikely causeFix
Failed to launch the browser processInsufficient memory, incompatible binary, wrong architecture, or invalid launch argumentsStart 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 existA bundler moved or inlined files used by @sparticuz/chromiumMark @sparticuz/chromium as an external dependency
spawn ... ENOENTThe executable path or layer contents are missingUse chromium.executablePath(), or verify the layer mount and chromium-min pack path
Function times outDefault timeout is too short, navigation never settles, or the site is slowSet 30–60 seconds and wait for a meaningful selector with a bounded timeout
Process exits with signal: killedChromium exhausted available memoryIncrease memory, reduce page size and concurrency, and close unused pages
Screenshot is blank or incompleteThe page was captured before client rendering finishedWait for the required selector, fonts, or a bounded network-idle period
PDF uses the wrong fontThe required font is unavailable in LambdaPackage the exact font in the function or a font layer
Response is rejected or truncatedBase64 output exceeds the integration’s payload limitUpload the file to S3 and return a reference
Works locally but fails on LambdaLocal Chrome, operating system, or architecture differsTest in an Amazon Linux-compatible container with the deployed package
Failed to launch the browser process
Likely cause
Insufficient memory, incompatible binary, wrong architecture, or invalid launch arguments
Fix
Start with 2,048 MB, verify x86_64 versus arm64, and use the documented Chromium arguments and path
1 of 9

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.

Author-Goutham
Goutham
LinkedIn

Hey, I’m Goutham - a techie who loves simplifying complex ideas. I design systems by day and break down tech jargon by night, always excited to share how awesome tech can be.

Share this article

Phone

Next for you

8 Best GraphQL Libraries for Node.js in 2025 Cover

Technology

Aug 4, 202613 min read

8 Best GraphQL Libraries for Node.js in 2025

8 Best GraphQL Libraries for Node.js in 2026 Too Long? Read This First - Choose Apollo Server when you need a mature ecosystem, GraphOS integration, plugins, or Apollo Federation. - Choose GraphQL Yoga for a modern, portable server with Fetch API compatibility and built-in support for subscriptions over Server-Sent Events. - Choose Mercurius when your application already uses Fastify and runtime efficiency is a major priority. - Use GraphQL.js when you need the official JavaScript implementati

9 React Native Animation Libraries and Tools Compared Cover

Technology

Aug 4, 202615 min read

9 React Native Animation Libraries and Tools Compared

Too Long? Read This First - Use React Native Reanimated for gesture-driven, interruptible, and performance-sensitive interface animations. - Use the built-in Animated API for simple fades, transforms, and timed sequences without another dependency. - Pair React Native Gesture Handler with Reanimated for swipes, dragging, pinching, rotation, and other touch-driven experiences. - Use Lottie React Native for non-interactive motion graphics supplied by designers. - Choose React Native Skia for cust

9 Critical Practices for Secure Web Application Development Cover

Technology

Aug 4, 202616 min read

9 Critical Practices for Secure Web Application Development

Too Long? Read This First - Define security requirements and model threats before implementation begins. - Treat authentication, account recovery, and MFA as one complete identity system. - Apply server-side authorization to every protected action and object. - Prevent injection with parameterized APIs, structured validation, safe output handling, and restricted outbound requests. - Protect sessions and tokens throughout their complete lifecycle. - Minimise sensitive data and manage encryption