· Tutorials · By Simon Chiu

Playwright on AWS Lambda: Slim Chromium Screenshots

Run Playwright on AWS Lambda with @sparticuz/chromium: handler code, package size vs Lambda limits, missing fonts, local testing with the runtime emulator.

To run Playwright on AWS Lambda, replace the playwright package with playwright-core and launch a Chromium build made for Lambda:

import chromium from '@sparticuz/chromium';
import { chromium as playwright } from 'playwright-core';

const browser = await playwright.launch({
  args: chromium.args,
  executablePath: await chromium.executablePath(),
});

@sparticuz/chromium ships a compressed Chromium with the libraries Lambda lacks. On the first call, executablePath() unpacks it into /tmp. The rest of your Playwright code stays the same. This post has the full handler, the sizes we measured against Lambda's limits, one rendering problem every multilingual site will hit (no fonts), and how to test it all locally before you deploy. For Playwright screenshot options in general, start with the Playwright screenshot guide.

Tested on 2026-10-18 with [email protected], @sparticuz/[email protected] (Chromium 153.0.8010.0) and the public.ecr.aws/lambda/nodejs:22 image, run locally with the Lambda Runtime Interface Emulator under linux/amd64 emulation on an Apple M1 Pro. We did not deploy to a real Lambda function for this post. Sizes and rendering results below are measured. Timings come from the emulator and are not Lambda timings.

Why the normal install does not work

Three things about Lambda break a plain npm i playwright:

  1. No browser download. playwright downloads Chromium into a cache folder at install time, outside your deployment package. Lambda only has what you upload.
  2. Missing system libraries. Playwright's own Chromium expects libraries that a Debian or Ubuntu install provides (libnss3, libgbm, and many more). The Lambda Node.js image is based on Amazon Linux 2023 and does not have them.
  3. Size and write limits. Code plus layers may be 250 MB unzipped, and only /tmp is writable (512 MB by default, configurable up to 10,240 MB). Values from the AWS Lambda quotas page, checked on 2026-10-18.

@sparticuz/chromium solves all three: one Brotli-compressed Chromium plus its libraries, unpacked to /tmp on cold start. playwright-core is the Playwright library with no browser download.

Version matching

Each Playwright release is built and tested against one Chromium build. [email protected] bundles Chromium 156. The newest @sparticuz/chromium on npm on our test date was 153.0.0, which is Chromium 153. Playwright drives it over the Chrome DevTools Protocol, and in our runs every call on this page worked. The Playwright docs do not promise that for any browser except the bundled one, so:

  • Pin both packages exactly (no ^).
  • Upgrade them together, and re-run your capture tests after each upgrade.
  • If a Playwright upgrade breaks against the older Chromium, stay on the previous Playwright minor until a matching @sparticuz/chromium ships.

The handler

// index.mjs
import chromium from '@sparticuz/chromium';
import { chromium as playwright } from 'playwright-core';

export const handler = async (event) => {
  const url = event.url ?? 'https://example.com';
  const browser = await playwright.launch({
    args: chromium.args,
    executablePath: await chromium.executablePath(),
    headless: true,
  });
  try {
    const context = await browser.newContext({ viewport: { width: 1280, height: 720 } });
    const page = await context.newPage();
    await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 15_000 });
    const png = await page.screenshot({ type: 'png' });
    return {
      statusCode: 200,
      headers: { 'content-type': 'image/png' },
      isBase64Encoded: true,
      body: png.toString('base64'),
    };
  } finally {
    await browser.close();
  }
};
{
  "name": "pw-lambda",
  "private": true,
  "type": "module",
  "dependencies": {
    "@sparticuz/chromium": "153.0.0",
    "playwright-core": "1.64.1"
  }
}

Design notes:

  • try/finally closes the browser even when goto throws. Lambda freezes the execution environment between calls; a browser left open keeps its memory and can leave zombie processes behind for the next call.
  • The handler validates nothing. In production, check that event.url is http or https and does not point at internal addresses (169.254.169.254, localhost, private ranges). A screenshot function that loads any URL is a server-side request forgery risk.
  • domcontentloaded plus a 15 s timeout keeps a slow page from using the whole function timeout. Add a locator wait for the content you need; see what to wait for in Playwright.
  • Do not import playwright anywhere in the bundle. Its install script downloads browsers and its size adds up.

Keep the browser between invocations?

You can launch the browser once outside the handler and reuse it while the execution environment stays warm. That saves the launch time on warm calls. The price: one crashed or leaked page affects every later call in that environment, and you need to check browser.isConnected() at the start of each call and relaunch when it is false. For low traffic, launch-per-call is simpler and safer. For steady traffic, reuse with a health check.

Measured: package size vs Lambda limits

Item Our build Lambda limit (AWS quotas page)
node_modules unzipped 86 MB (@sparticuz/chromium 67 MB, playwright-core 13 MB) 250 MB unzipped, code plus layers
Deployment zip (zip -r) 75 MB 50 MB for direct upload; larger through S3
Chromium unpacked in /tmp 200 MB, plus about 10 MB of libraries and fonts 512 MB default /tmp, up to 10,240 MB
Sync response body A 1280 x 720 PNG of example.com, base64 encoded 6 MB per synchronous response

Two consequences:

  • The zip is too big for a direct upload. Upload through an S3 bucket, or move @sparticuz/chromium into a Lambda layer (the project publishes layer zips on its GitHub releases) and keep your function zip small.
  • Large screenshots will hit the 6 MB response limit. Base64 adds a third to the size, so a PNG above about 4.5 MB fails as a direct response. Full-page PNGs at 2x get there quickly. Write the image to S3 and return its key or a presigned URL instead.

Measured: missing fonts

example.com now prints the same notice in six languages, including Arabic and Chinese, which makes it a quick font test. On the Lambda image with @sparticuz/chromium alone, the English, French, Russian and Spanish paragraphs rendered, and the Arabic and Chinese paragraphs rendered as empty space with only their punctuation visible. No error, no warning: the PNG looked normal at a glance.

The cause is in the package README: the Lambda runtime has no fonts, and the package ships Open Sans only (Latin, Greek and Cyrillic). The fix is to add font files to a folder Chromium's fontconfig reads. The documented folders are /var/task/.fonts, /var/task/fonts, /opt/fonts and /tmp/fonts:

your-function/
├── index.mjs
├── package.json
├── node_modules/
└── fonts/
    ├── NotoSansCJK-Regular.ttc
    └── NotoSansArabic-Regular.ttf

We tested this with two fonts copied from the Playwright Ubuntu image (WenQuanYi Zen Hei for Chinese, FreeSerif for Arabic). Both scripts rendered. One side effect surprised us: with FreeSerif present, fontconfig picked it as the fallback for system-ui, so the Latin text switched from Open Sans to a serif face. Add a sans-serif font that covers each script you need (the Noto families do), and check a Latin page again after you add fonts.

Fonts add size: our two files were about 20 MB. For CJK, a layer is the right place for them. Emoji need a color emoji font in the same folder. The consistent fonts guide explains fallback order in more depth.

Test locally with the runtime emulator

The AWS Lambda base images include the Runtime Interface Emulator, so you can run the real Amazon Linux environment on your machine:

npm ci
docker run --rm --platform linux/amd64 -p 9000:8080 \
  -v "$PWD":/var/task:ro \
  public.ecr.aws/lambda/nodejs:22 index.handler

In a second terminal:

curl -s -X POST localhost:9000/2015-03-31/functions/function/invocations \
  -d '{"url":"https://example.com"}' -o response.json
python3 -c "import json,base64; d=json.load(open('response.json')); open('shot.png','wb').write(base64.b64decode(d['body']))"

That is how we found the font problem. Use --platform linux/amd64 because the npm package contains only x64 binaries. On an Apple Silicon Mac, Docker emulates x64, which is slow: our first call took 11.0 s and later calls about 6.2 s and 6.5 s end to end, with browser launch at 2.6 s cold and 1.1 to 1.2 s after that. Those numbers measure the emulator on a laptop, not Lambda. Measure on a real function with the memory size you plan to use before you set timeouts.

For arm64 (Graviton) functions, the README says to use @sparticuz/chromium-min with the arm64 layer or pack from the project's GitHub releases, because the main npm package is x64 only.

Memory and timeout settings

From the package README and the AWS quotas page:

  • Memory: the README asks for at least 512 MB and recommends 1,600 MB or more. Lambda gives CPU in proportion to memory, reaching one full vCPU at 1,769 MB, and Chromium rendering is CPU bound. Start at 2,048 MB and measure.
  • Timeout: the default 3 s is too short for any browser launch. Our emulator runs needed several seconds; set the function timeout above your goto timeout plus launch and capture time, and keep the goto timeout below it so you get a clean error instead of a killed function.
  • Ephemeral storage: the unpacked browser used about 210 MB of /tmp. The 512 MB default fits one browser, but a handler that also writes large screenshots to /tmp should raise it.

Troubleshooting

Symptom Cause Fix
browserType.launch: Executable doesn't exist Code imports playwright or omits executablePath Use playwright-core and pass await chromium.executablePath()
error while loading shared libraries on launch Playwright's own Chromium on Amazon Linux Use the @sparticuz/chromium binary, not a downloaded one
Upload fails with a size error Zip is above 50 MB Upload through S3 or move Chromium to a layer
Blank areas where CJK, Arabic or Thai text should be No font for that script Add fonts to /var/task/fonts or a layer at /opt/fonts
Latin text changes typeface after adding fonts A new font became the fallback for generic families Add a sans-serif font per script; re-check Latin pages
Task timed out after 3.00 seconds Default function timeout Raise the timeout; keep goto timeout below it
Response fails for large images 6 MB synchronous response limit, base64 overhead Write to S3 and return a key or presigned URL
Crashes or Target page, context or browser has been closed under load Memory too low for the page Raise memory; close contexts; avoid many pages per call
exec format error locally on Apple Silicon x64 binaries in an arm64 container Run Docker with --platform linux/amd64
Works after deploy, breaks after a Playwright upgrade Playwright and Chromium versions drifted apart Pin both and upgrade them together

When a function is the wrong shape

Lambda is a good fit when screenshots are a small, bursty side job and you are already on AWS. It is a weaker fit when screenshots are the product: you own the font set, the version pairing, the cold starts, the SSRF checks and the 6 MB limit. Cloudflare's model is different again; our Cloudflare Browser Rendering vs AWS Lambda comparison covers the trade-offs, and Playwright on Cloudflare Browser Rendering shows the code.

RenderScreenshot runs on Cloudflare Browser Rendering, and the screenshot comes back from one HTTP call with options that map to the ones above. The handler above becomes a fetch from any runtime, Lambda included:

import { Client, TakeOptions } from 'renderscreenshot';

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

export const handler = async (event) => {
  const image = await client.take(
    TakeOptions.url(event.url).width(1280).height(720).format('png')
  );
  return {
    statusCode: 200,
    headers: { 'content-type': 'image/png' },
    isBase64Encoded: true,
    body: image.toString('base64'),
  };
};

The function then needs the default memory and a small zip. The Node SDK reference lists every option, and you can create a free key to try it.

Related in this topic