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 thepublic.ecr.aws/lambda/nodejs:22image, run locally with the Lambda Runtime Interface Emulator underlinux/amd64emulation 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:
- No browser download.
playwrightdownloads Chromium into a cache folder at install time, outside your deployment package. Lambda only has what you upload. - 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. - Size and write limits. Code plus layers may be 250 MB unzipped, and only
/tmpis 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/chromiumships.
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/finallycloses the browser even whengotothrows. 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.urlishttporhttpsand 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. domcontentloadedplus 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
playwrightanywhere 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/chromiuminto 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
gototimeout plus launch and capture time, and keep thegototimeout 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/tmpshould 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
- Playwright Screenshots: Tested Recipes and Pitfalls Take screenshots with Playwright: page.screenshot options, deviceScaleFactor, locators, masks, measured format sizes, auth, Linux fonts and production setup.
- Playwright Full Page Screenshot: Limits and Lazy Images Playwright full page screenshot guide: fullPage limits measured per engine, a wheel-scroll helper for lazy images and the style option for sticky headers.
- Playwright Python Screenshots: Sync and Async Take screenshots with Playwright for Python: sync vs async API side by side, full-page and element captures, pytest failure shots, measured timings.
- Playwright toHaveScreenshot: Visual Tests That Hold Set up Playwright visual comparisons with toHaveScreenshot: thresholds that mean something, OS font drift, Docker baselines and CI artifacts. Measured diffs.
- Playwright Wait for Page Load: What to Wait For How to make Playwright wait for a page to load before a screenshot: load states, why networkidle is discouraged, locator auto-waiting, and timed results.
- How to Use Playwright with Cloudflare Browser Rendering A practical tutorial on running Playwright inside Cloudflare Workers using the Browser Rendering API — installation, locators, auto-waiting, session reuse, and gotchas from running it in production.