Running Puppeteer on AWS Lambda: The 50 MB Limit, Cold Starts, and What Self-Hosting Actually Costs

Both are the same underlying fact expressing itself at different moments: a real browser is a large, native, OS-dependent binary, and Lambda is a packaging format designed for small, portable code… puppeteer-core is the same API with no bundled browser, and you point it at the binary yourself… …

Most people arrive at this problem in one of two states. Either the deploy failed outright — Unzipped size must be smaller than 262144000 bytes — or the deploy succeeded and the function throws on the first invocation because Chromium can’t be spawned. Both are the same underlying fact expressing itself at different moments: a real browser is a large, native, OS-dependent binary, and Lambda is a packaging format designed for small, portable code.
It is entirely possible to run Puppeteer on Lambda, in production, reliably. Thousands of teams do. This article is the version of the guide that doesn’t stop at the happy path: how the size limit works, the handler shape that survives warm invocations, the timeouts that will bite you after it “works,” and then the part almost nobody writes down — what the whole arrangement costs over a year, including the line items that aren’t compute.
Why Chromium doesn’t fit in a Lambda package
Three limits matter, and they’re often conflated:
- 50 MB zipped for a direct upload of a deployment package.
- 250 MB unzipped for function code plus all attached layers, combined. Uploading via S3 lets the zip itself be larger, but this unzipped ceiling still applies — it’s the one behind the
262144000 byteserror. - 10 GB for a container image.
A full Chromium build unpacks to several hundred megabytes. It does not fit the zip path, and no amount of pruning node_modules changes that. This is the entire reason @sparticuz/chromium exists — the maintained successor to the long-archived chrome-aws-lambda. It ships a compressed Chromium build that fits inside the unzipped limit, and decompresses it into /tmp at runtime, on first use, inside the execution environment.
That decompression step is not free, and it’s most of what people are actually measuring when they complain about cold starts.
The second half of the trick is puppeteer-core instead of puppeteer. The full puppeteer package downloads its own Chromium at install time; if it ends up in your bundle you’re back over the limit. puppeteer-core is the same API with no bundled browser, and you point it at the binary yourself.
A handler that actually works
import chromium from "@sparticuz/chromium";
import puppeteer from "puppeteer-core";
// Declared outside the handler so it survives warm invocations.
let browser;
async function getBrowser() {
if (browser?.connected) return browser;
browser = await puppeteer.launch({
args: chromium.args,
executablePath: await chromium.executablePath(),
defaultViewport: { width: 1280, height: 720 },
headless: chromium.headless,
});
return browser;
}
export const handler = async (event) => {
const target = event.url;
const instance = await getBrowser();
const page = await instance.newPage();
try {
await page.goto(target, { waitUntil: "networkidle2", timeout: 20_000 });
const buffer = await page.screenshot({ fullPage: true, type: "png" });
return { ok: true, bytes: buffer.length };
} finally {
await page.close();
}
};
Four things in there are load-bearing.
chromium.args is not decoration. It carries the flag set that makes Chromium tolerate a container with no GPU, no display server, and a restricted sandbox. Hand-rolling that list is a reliable way to spend an afternoon rediscovering --no-sandbox and --single-process.
The module-scope browser variable is the single largest performance win available to you, because Lambda reuses execution environments. A warm invocation that skips both the /tmp decompression and the browser boot is in a completely different cost class. But it must be guarded: Lambda freezes the process between invocations rather than letting it run, so a browser handle can come back dead, and calling newPage() on a dead handle throws in a way that looks nothing like the actual cause. Hence browser?.connected — older Puppeteer versions spell it browser.isConnected().
The finally { page.close() } matters for the same reason the reuse works. Pages you forget to close persist into the next invocation in that environment. Do it a few hundred times and the function starts dying with out-of-memory errors on requests that look identical to the ones that worked yesterday.
And the explicit timeout on goto is there because Puppeteer’s default is 30 seconds, which — as the next section covers — is longer than the wall you’re about to hit.
Recent versions of @sparticuz/chromium also expose chromium.setGraphicsMode = false, which skips the software graphics stack. If you’re rendering plain pages rather than WebGL canvases, it’s worth measuring.
Memory, /tmp, and fonts
Memory. Chromium needs substantially more than a default Lambda allocation, and the important detail is that Lambda scales CPU with memory. Under-provisioning doesn’t just risk an OOM kill, it makes the cold start worse — you’re decompressing a browser and booting it on a fraction of a vCPU. And because Lambda bills GB-seconds, doubling memory to halve duration is roughly cost-neutral. More memory is not automatically a bigger bill. Provision generously, measure the actual billed duration in both configurations, and pick from data rather than from instinct.
/tmp. The default is 512 MB, configurable up to 10,240 MB. It has to hold the decompressed Chromium plus whatever the browser writes there — user data directory, caches, crash dumps — and those files survive across warm invocations in the same environment. A function that runs fine for an hour and then starts failing is very often a full /tmp. Either clean up after yourself or raise the allocation.
Fonts. The Lambda runtime environment ships close to nothing in the way of fonts. Latin text usually renders acceptably; CJK, Arabic, and emoji come out as boxes. This is one of the more common “the screenshot is technically fine but visibly wrong” failures, and it’s a cousin of the other rendering failures catalogued in our field guide to debugging blank and broken screenshots — same symptom class, different root cause. @sparticuz/chromium exposes a font() helper to load a font file into /tmp before launch, which is the fix, and also more /tmp pressure.
Cold starts. Be suspicious of any article giving you a single number. The honest statement is that a cold invocation is measured in seconds, not milliseconds, and that the decompress-plus-boot sequence dominates it. How many seconds depends on your memory allocation, your package format, your Chromium version, and whether that particular environment already has the binary in /tmp. Measure yours. The one structural thing worth knowing: provisioned concurrency removes the cold start by keeping environments warm, and it bills for that time whether you invoke or not — which converts a latency problem into a fixed monthly cost.
Container images (the 10 GB path) sidestep the size limit entirely and let you bake a normal, uncompressed Chromium into the image. They are the right call for genuinely large dependency trees. They also bring their own cold-start profile, an ECR repository to store and pay for, and a lifecycle policy to write so that eighteen months of stale image layers don’t quietly accumulate.
The 29-second wall (and the 6 MB one)
Here is the failure that arrives after everything works locally: a cold start plus a slow page can easily exceed API Gateway’s 29-second integration timeout. The client gets a 504. The Lambda keeps running, finishes the screenshot successfully, and writes a cheerful success line to CloudWatch — so your logs and your users disagree about what happened. Lifting that limit is a service-quota conversation, not a checkbox.
Two workarounds, both structural rather than clever:
- Lambda Function URLs. No API Gateway in the path, so no 29-second integration timeout, and one less component to configure. You give up the gateway’s authorizers, request validation, and usage plans.
- Async invocation. Accept the request, return
202immediately with a job ID, invoke the capture asynchronously, write the resulting image to S3, and let the client poll or receive a webhook. This is more moving parts and the correct answer if you’re capturing anything genuinely slow.
The second limit in the same family: a synchronous Lambda response payload caps at 6 MB. A fullPage: true PNG of a long marketing page can exceed that on its own, and a base64-encoded one hits the wall at about 4.5 MB of actual image. If you’re returning image bytes through the function, you will eventually hit this. Writing to S3 and returning a presigned URL is the standard fix, and it’s worth doing before you need it.
While you’re in that neighbourhood: networkidle2 is a heuristic, not a guarantee, and a page whose hero images load on scroll will screenshot half-empty no matter how patiently you wait for the network to go quiet. The techniques in waiting for lazy-loaded images and animations apply identically whether the browser is yours or someone else’s — this is a property of the page, not of the runtime.
What it costs, honestly
I’m not going to quote AWS prices. They vary by region, they change, and a number copied out of a blog post is worse than no number. What’s durable is the cost structure, and you can put your own rates into it from the AWS calculator.
Compute is memory in GB × billed duration in seconds × invocations, plus a per-request charge. Concretely: at 2 GB with a 3-second warm capture, that’s 6 GB-seconds per screenshot. Fifteen thousand captures a month is 90,000 GB-seconds, plus whatever your cold-start ratio adds on top. Run that through the calculator for your region rather than trusting anyone’s estimate of it, including mine.
The managed comparison point is easy to state exactly: €240 a year is what the Ultra plan costs (€20 a month for 15,000 requests, which works out to €1.33 per 1,000 captures; Mega AI is €39 for 30,000, or €1.30 per 1,000). So let’s be blunt about it: at mid volume, raw Lambda compute can beat a managed API on the compute line. Anyone telling you otherwise is selling something.
The reason that comparison is still misleading is everything the compute line omits:
- CloudWatch Logs. Chromium is chatty and every launch logs. You pay for ingestion and for retention, and the default retention is never expire. Set a retention policy on day one.
- ECR storage, if you took the container route, plus the data transfer to pull images.
- NAT Gateway. If the function lives in a private subnet — which it will, the moment it needs to reach an RDS instance or an internal host — you pay an hourly charge per gateway plus per-GB processing on everything it fetches. For a browser downloading full page assets, that per-GB figure is not small. It is routinely the largest line on the bill, larger than the Lambda itself.
- S3 storage and egress for the images you keep.
- Provisioned concurrency, if cold starts turned out to be unacceptable.
- Engineering hours.