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

Snapshot Site Team
03 Aug 2026 - 10 Mins read
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.
That last one is the whole ballgame at small and mid volume, and it's the only line item people systematically leave out of the spreadsheet. Getting to a first working deploy is a day or two for an engineer who has done it before, and considerably more for one who hasn't — the failure modes above are each an afternoon if you meet them cold. One loaded engineer-day at any plausible European rate exceeds a year of Ultra. You are not choosing between compute bills; you're choosing what to spend attention on.
If cost per capture is what you're optimising, the highest-leverage move in either architecture is not capturing the same URL twice — the client-side caching pattern is worth more than any amount of memory tuning, because a capture you skip costs nothing in both models.
The line item nobody budgets: Chromium version churn
Three things have to stay mutually compatible: the Chromium build, the @sparticuz/chromium release that packages it, and the puppeteer-core version whose protocol expectations must match that browser. Upgrade one in isolation and you get a launch that fails, or worse, a launch that succeeds and renders subtly wrongly.
The part that makes this a recurring cost rather than a one-time one is that the schedule isn't yours. A site starts depending on a browser feature your pinned build doesn't have. A security advisory lands against your Chromium version. Your Node runtime reaches end of support and Lambda deprecates it on AWS's timeline, which means rebuilding the layer or the image regardless of whether anything about your code changed. None of these are hard, individually. All of them arrive unannounced, and each one is an afternoon somebody didn't plan for.
Budget it as a small standing tax on an engineer's attention, not as a project. Teams that treat browser infrastructure as "done" are the ones for whom it breaks loudest.
Where each option actually wins
Self-hosting on Lambda is the right call — not the tolerable call, the right one — in several real situations:
Very high sustained volume. The unit economics genuinely flip. At hundreds of thousands or millions of captures a month, per-capture compute cost is the dominant term, the fixed maintenance overhead amortises to nearly nothing per screenshot, and you should own the browser.
Data residency and confidentiality constraints. If the pages you're capturing are internal, authenticated, or carry personal data that must not leave your AWS account or your jurisdiction, that decides it before any cost conversation starts. A function inside your VPC capturing your own admin panel is the correct architecture, full stop.
You need real control of the browser. Specific Chromium flags, a pinned browser version for reproducibility, a loaded extension, raw CDP access, or multi-step interaction — logging in, filling a form, clicking through a wizard, capturing state at step four. Injected JavaScript covers a surprising amount of ground, but it does not cover arbitrary multi-step navigation.
Burst concurrency. Lambda will hand you a thousand parallel browsers on request, which is a real structural advantage over any rate-limited API — and if you go that route, bounded concurrency patterns still apply, because the constraint just moves to the sites you're capturing and to your own account limits.
You already have a platform team. If browser infrastructure is somebody's actual job and there's an on-call rotation, the maintenance tax is already paid.
Where it's the wrong call is equally specific: a few hundred to a few thousand captures a month, on a team with no spare platform capacity, where screenshots are a feature of your product rather than the product itself. At that scale you'll spend more on the first working deploy than on several years of API calls, and then you'll keep paying the churn tax forever, for a component nobody wants to own.
The same handler, without the browser
The framing that clears this up: you're not choosing between Lambda and an API. You're choosing what runs inside the Lambda.
export const handler = async (event) => {
const res = await fetch(
"https://api.prod.ss.snapshot-site.com/api/v1/screenshot",
{
method: "POST",
headers: {
"Content-Type": "application/json",
"x-snapshotsiteapi-key": process.env.SNAPSHOT_SITE_API_KEY,
},
body: JSON.stringify({
url: event.url,
format: "png",
width: 1280,
fullSize: true,
delay: 2,
hideCookie: true,
}),
},
);
const { link } = await res.json();
return { ok: true, link };
};
That's a 128 MB function with no dependencies at all and no native binaries. The 250 MB limit stops being a concept you have to know about, /tmp is irrelevant, the cold start is a Node cold start, and the font environment isn't yours to provision. The response carries a link to the stored image, so the 6 MB payload ceiling stops applying too.
The parameters map onto the Puppeteer options you'd otherwise be writing by hand: fullSize is fullPage, delay is a settle wait in seconds (an integer from 0 to 10), hideCookie removes common consent banners, and on the v2 endpoint hide takes comma-separated CSS selectors while javascriptCode (Ultra and above) runs your own script before capture — which is where most of the page.evaluate() work you'd have written goes. Which endpoint you want depends on how much control you need; v1 vs v2 vs v3 lays out the split.
What this does not do is make the hard parts of screenshotting go away. Slow pages are still slow, transient failures still need retries and backoff, and a page that renders its content on scroll will still need a delay or an injected nudge. Rendering the web is awkward wherever you do it. The difference is which awkwardness you're personally on the hook for at 3 a.m.
If the honest answer for your volume is that the Chromium layer isn't worth owning, the free tier on Snapshot Site is 50 captures a month — enough to swap the handler above into your existing Lambda and see what your latency and your bill look like without the browser.





