Taking Screenshots in AWS Lambda Without Fighting Chromium
Running Chromium inside AWS Lambda is one of those tasks that looks like a two-hour job on Stack Overflow and turns into a multi-day yak shave. The browser doesn't fit in the deployment package, the version you install doesn't match the library that drives it, the fonts are missing, the cold start eats your latency budget, and the default timeout kills the render halfway through. Every single one of these problems is solvable — people run Puppeteer in Lambda in production every day — but each fix has a sharp edge, and the edges compound.
This guide covers both paths honestly. First, the real pain points with specifics — not "it's
hard" but exactly which limits you'll hit and why. Then a complete, working self-hosted setup with
@sparticuz/chromium and puppeteer-core, because sometimes that's the right
call. Then the cost math, which is more interesting than you'd expect: Lambda compute is
cheap; it's everything around it that's expensive. And finally the alternative — a Lambda
function that calls a screenshot API and deploys as a few kilobytes.
Why Chromium and Lambda fight each other
100 renders / month, no credit card. Or buy credits one-time and use them whenever — credits never expire.
The 250 MB unzipped limit
A Lambda deployment package — function code plus all layers — must be under 250 MB
unzipped. A standard Chromium build is around 280 MB on its own, before your code and
node_modules. It simply doesn't fit.
The standard workaround is @sparticuz/chromium, the community-maintained successor
to the now-archived chrome-aws-lambda. It ships Chromium as a Brotli-compressed
archive (~65-80 MB in the package) that decompresses into /tmp on first invocation.
That gets you under the limit, but the build is stripped: reduced codec support, almost no bundled
fonts, and a flag set tuned for headless rendering rather than fidelity.
The other escape hatch is deploying your Lambda as a container image, which
raises the ceiling to 10 GB. You write a Dockerfile, apt-get install a full Chrome
plus font packages, and skip the stripped build entirely. It genuinely works and solves the font
problem in one line — the trade-offs are slower cold starts while the image pulls, an ECR
repository to manage, and a Docker build in your CI pipeline for what used to be a zip file.
Version pinning: the dependency treadmill
This is the failure mode that bites people months after launch. puppeteer-core
only speaks to Chromium versions it was built against, and @sparticuz/chromium
releases track Chromium's release cycle — its major version is the Chromium major version.
Pair @sparticuz/chromium@131 with a puppeteer-core that expects
Chromium 133 and you get protocol errors that look nothing like a version mismatch:
Protocol error (Target.setAutoAttach), hangs on newPage(), silent
crashes.
The practical consequence: you must pin both packages, consult the compatibility table
in the @sparticuz/chromium README before every upgrade, and never let Dependabot
auto-merge either one. Chromium ships security releases every few weeks, so "pin it and forget it"
means accumulating CVEs in the thing you use to load untrusted web pages.
Cold starts: 2-6 seconds
A cold invocation pays for three things before your first screenshot: initializing the Node
runtime, decompressing Chromium into /tmp, and launching the browser process. At
1,600 MB of memory that's typically 2-4 seconds; at 1,024 MB, closer to 4-6, because Lambda
allocates CPU proportionally to memory — less memory literally means a slower CPU for the
single-threaded decompress-and-boot sequence. For a background job this is tolerable. For anything
user-facing (link previews, on-demand OG images) it's a visible stall, and the standard fix —
provisioned concurrency — means paying for warm Lambdas around the clock.
Memory: 1,024 MB is the floor, not the target
Chromium plus Node plus a real web page needs room. At 512 MB, non-trivial pages OOM-kill the
browser mid-render. 1,024 MB works for simple pages but is fragile — one image-heavy marketing
site or an infinite-scroll page and you're reading
Navigating frame was detached errors that are actually memory exhaustion in disguise.
1,600 MB is the realistic recommendation: enough headroom for heavy pages, and the
extra CPU that comes with it cuts both cold start and render time, which partially pays for
itself since Lambda bills in GB-seconds.
/tmp: 512 MB by default, and Chromium wants a lot of it
The decompressed browser is ~250-300 MB in /tmp. Chromium then writes its profile,
cache, and shared-memory scratch (it's launched with --disable-dev-shm-usage, which
redirects /dev/shm traffic to disk) into the same space. On long-lived warm containers
rendering large pages, the default 512 MB can fill up and produce baffling crashes. You can raise
ephemeral storage to 10 GB — for an extra per-GB-second fee.
Fonts: tofu boxes in production
Amazon Linux ships with almost no fonts. Render a page with Japanese, Chinese, Arabic, or emoji
and you get rows of hollow rectangles — the dreaded tofu. Your tests pass because you tested on
english-language pages; the bug report arrives when a customer screenshots a page with a ★ or a 中
in it. Fixes: bundle Noto Sans CJK and Noto Color Emoji into a layer and point Fontconfig at them,
or use @sparticuz/chromium's chromium.font() helper to pull font files
into /tmp/fonts at runtime (more cold-start latency), or go the container-image route
and apt-get install fonts-noto-color-emoji fonts-noto-cjk.
ARM64: Graviton is cheaper, and you can't use it
Lambda's ARM64/Graviton runtime is ~20% cheaper per GB-second, so it's tempting to flip the
architecture switch. Don't: @sparticuz/chromium publishes x86_64 binaries only —
Google doesn't ship official Linux ARM64 Chromium builds, and compiling your own is a genuinely
serious undertaking. Deploy to an ARM64 Lambda and the binary fails on the first invoke. Your
screenshot function stays on x86_64.
Timeouts and concurrency
Lambda's default timeout is 3 seconds — your function will be killed before Chromium finishes booting. Set it to 30-60 seconds. And remember each render holds a full Lambda (and 1,600 MB) for its entire duration, drawing from your account-wide concurrency pool of 1,000: a screenshot traffic spike can throttle every other function in the account until you set reserved concurrency.
The self-hosted setup that actually works
To be fair to this approach: once configured, it does work. Here's the honest minimum. Pin the
pair in package.json (check the compatibility table for current versions):
{
"dependencies": {
"@sparticuz/chromium": "131.0.0",
"puppeteer-core": "23.10.4"
}
}
The handler:
// handler.mjs
import chromium from "@sparticuz/chromium";
import puppeteer from "puppeteer-core";
export const handler = async (event) => {
const { url } = JSON.parse(event.body ?? "{}");
// Optional: load fonts for emoji/CJK before launch (adds latency)
// await chromium.font("https://your-bucket.s3.amazonaws.com/NotoColorEmoji.ttf");
const browser = await puppeteer.launch({
args: chromium.args,
defaultViewport: { width: 1280, height: 800 },
executablePath: await chromium.executablePath(),
headless: "shell",
});
try {
const page = await browser.newPage();
await page.goto(url, { waitUntil: "networkidle2", timeout: 25000 });
const png = await page.screenshot({ type: "png", fullPage: true });
return {
statusCode: 200,
headers: { "Content-Type": "image/png" },
body: Buffer.from(png).toString("base64"),
isBase64Encoded: true,
};
} finally {
await browser.close();
}
};
Required configuration, none of it optional: memory 1,600 MB, timeout 60 s, architecture
x86_64, and ideally ephemeral storage above the default. Always browser.close() in a
finally block — a leaked browser process in a warm container is a memory leak that
survives across invocations and eventually OOMs a request that did nothing wrong.
What this snippet does not handle, and production will demand: SSRF protection if URLs come from users (nothing stops this code from screenshotting your VPC-internal endpoints), retry logic for pages that hang, bot-detection walls from Cloudflare and friends, PDF output tuning, and the quarterly ritual of bumping both pinned packages together and re-testing. The Puppeteer vs. API breakdown goes deeper on that maintenance tail.
The cost math (Lambda compute is not the expensive part)
Let's be precise, because the numbers surprise people. Lambda x86 pricing is $0.0000166667 per GB-second. A render at 1,600 MB (1.5625 GB) taking ~4 seconds of wall time costs:
- 1.5625 GB × 4 s = 6.25 GB-seconds → ~$0.0001 per screenshot
- 10,000 screenshots/month ≈ $1.04 in compute
- 100,000 screenshots/month ≈ $10.40 in compute
Compare a screenshot API: at $19 for 2,000 renders that's ~$0.0095 per render, dropping to ~$0.004 at the $299/75,000 tier. So per render, self-hosted Lambda compute is roughly 40-90x cheaper. If raw compute were the whole story, this article would end here and recommend self-hosting.
It isn't the whole story. The real line item is engineering time. A realistic accounting:
- Initial build: 1-3 days to get from the snippet above to something production-ready — fonts, SSRF filtering, timeout and retry handling, S3 upload, monitoring.
- Ongoing: a few hours per month — Chromium/puppeteer version bumps (security patches ship every few weeks), the occasional mystery OOM, font regressions on new content.
At a blended $100/hour, the initial build is $800-2,400 and maintenance runs $200-400/month. Against a $19-79/month API bill, the compute savings of $10 per 100k renders never catches up until volume gets serious. The honest crossover: at ~500,000+ renders/month, the per-render delta reaches ~$2,000/month and self-hosting starts genuinely paying for the engineer who tends it. Below that, you're spending dollars of attention to save cents of compute.
The alternative: a 3 KB Lambda that calls an API
The other architecture moves the browser out of your Lambda entirely. Your function becomes a
plain HTTP client — no layers, no binaries, no fonts, no version pinning. Here's a complete
handler using nothing but built-in fetch:
// handler.mjs — no dependencies at all
export const handler = async (event) => {
const { url } = JSON.parse(event.body ?? "{}");
const resp = await fetch("https://screenshotapis.org/v1/screenshot", {
method: "POST",
headers: {
"X-Api-Key": process.env.SCREENSHOT_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({ url, format: "png", full_page: true }),
});
if (!resp.ok) {
return { statusCode: 502, body: JSON.stringify({ error: "Render failed" }) };
}
const buf = Buffer.from(await resp.arrayBuffer());
return {
statusCode: 200,
headers: { "Content-Type": "image/png" },
body: buf.toString("base64"),
isBase64Encoded: true,
};
};
Configuration: 128 MB memory, default architecture (ARM64 is fine now — there's no binary), deployment package a few kilobytes. Cold start is ~150-250 ms because there's nothing to initialize. The render itself happens on the API's Playwright/Chromium fleet — typical render time is 0.9-1.6 seconds — so your Lambda spends its billed time idling at 128 MB, which rounds to fractions of a cent per 10,000 invocations. Fonts, emoji, CJK, ad blocking, SSRF filtering, and PDF output are the API's problem, not yours.
If you prefer an SDK over raw fetch, npm install screenshotapis (also on PyPI)
gives you a typed client — the Node.js tutorial
walks through it, including rendering raw HTML for OG images and webhook-based async rendering for
big batch jobs. Full parameter reference lives in the docs, and you can
prototype the exact request in the interactive screenshot
generator before writing any code.
The same handler runs unmodified on Vercel Functions, Netlify Functions, and Fly — and on Cloudflare Workers, where this isn't a preference but the only option: Workers cannot run Chromium at all outside Cloudflare's own paid browser-rendering binding.
When self-hosting is the right call
An API is not always the answer, and pretending otherwise would make this article useless. Keep Chromium in your own infrastructure when:
- Volume is genuinely high — north of ~500k renders/month, per-render pricing loses to owned infrastructure, provided someone owns the maintenance.
- Data locality or compliance — the pages you render contain regulated data (healthcare, finance, government) that contractually cannot transit a third-party service, or you must render inside a specific AWS region or VPC.
- Air-gapped or private-network targets — internal dashboards and intranet
pages that an external API can't reach by design. No hosted service can screenshot
http://10.0.3.17for you, and that's a feature. - Exotic browser requirements — custom Chromium flags, patched builds, or fingerprinting setups no API exposes.
For everyone else — bursty traffic, small teams, link previews, OG images, PDF invoices, visual archives — the API path is a free API key (100 renders/month, no card), five lines of code, and one less binary to babysit. If your volume is spiky rather than steady, one-time credit packs from $9 that never expire fit better than a subscription.
Frequently asked questions
Can I run Puppeteer directly in AWS Lambda?
Not the full puppeteer package — its bundled Chromium exceeds Lambda's 250 MB
unzipped limit and isn't built for Amazon Linux. The working combination is
puppeteer-core plus @sparticuz/chromium, a stripped, Brotli-compressed
Chromium build that decompresses into /tmp at runtime. It works, but you must pin
both packages to compatible major versions and re-verify on every upgrade.
Why do emoji and Chinese characters show as boxes in my Lambda screenshots?
Amazon Linux ships with almost no fonts, and the stripped Chromium build bundles none for CJK
or emoji, so those glyphs render as tofu boxes. Fix it by bundling Noto fonts into a layer, loading
them at runtime with chromium.font(), or switching to a container-image Lambda where
you can apt-get install full font packages.
How much memory does a Chromium Lambda need?
1,024 MB is the realistic floor and still fragile on heavy pages; 1,600 MB or more is the sensible default. Lambda allocates CPU proportionally to memory, so higher memory also cuts cold starts and render time — 1,600 MB is often no more expensive per render than a slower 1,024 MB configuration.
Does @sparticuz/chromium work on ARM64 (Graviton) Lambdas?
No. It ships x86_64 binaries only, because Google doesn't publish official Linux ARM64 Chromium builds. Despite Graviton's ~20% lower price, a Chromium-based screenshot function must run on x86_64. A Lambda that calls a screenshot API over HTTP has no such restriction.
What does a screenshot cost on Lambda?
Pure compute is cheap: at 1,600 MB and ~4 seconds per render, about $0.0001 per screenshot, or roughly $10 per 100,000. The real cost is engineering time — 1-3 days of initial setup plus ongoing Chromium security updates, version-pinning maintenance, and font/OOM debugging, which typically dwarfs the compute savings below ~500k renders/month.
Do I need to bundle Chromium at all to take screenshots from Lambda?
No. Your Lambda can POST to a screenshot API and receive back a PNG, JPEG, WebP, or PDF. The function needs no layers, runs in 128 MB, deploys as a few kilobytes, cold-starts in ~200 ms, and the same code works on Vercel, Netlify, Fly, and Cloudflare Workers — where running Chromium yourself isn't even possible.