The journal · 19 July 2026 · 9 min read
Running Puppeteer PDF generation on AWS Lambda: the gotchas
Bundling headless Chrome into a Lambda function is a rite of passage that fights you at every step: the deployment size limit, the binary, cold starts, memory. Here is the working setup, and the honest point where it stops being worth it.
Running Puppeteer on AWS Lambda to generate PDFs is one of those tasks that looks like a weekend job and turns into a week. The regular puppeteer package downloads a full Chromium that is far too big for a Lambda deployment, so nothing works until you swap in a Chromium built for the environment, and then you meet cold starts, memory limits and timeouts in turn. It is entirely doable, and plenty of teams run it in production. This post is the working setup, the specific gotchas in the order you hit them, and the honest point where serverless headless Chrome stops being worth owning.
Gotcha one: the deployment size limit
A Lambda deployment package has a hard ceiling: 50 MB zipped for a direct upload, 250 MB unzipped. The standard puppeteer package bundles a Chromium download that blows straight past that. This is the wall every tutorial starts at, and the fix is to not ship full Chromium at all.
The current answer is @sparticuz/chromium, a Chromium build stripped and compressed specifically to fit Lambda, paired with puppeteer-core (the Puppeteer library without its own bundled browser). The older chrome-aws-lambda package that most stale blog posts reference is effectively abandoned; @sparticuz/chromium is its maintained successor and the one to use in 2026. Even compressed, the binary is large enough that many teams ship it as a Lambda layer or pull it from S3 rather than inlining it in the function zip.
// package.json
{
"dependencies": {
"@sparticuz/chromium": "^123.0.0",
"puppeteer-core": "^22.0.0"
}
}
Gotcha two: launching the right binary
With the packages in place, the launch code has to point Puppeteer at the Lambda Chromium and use its recommended flags. Get one path or flag wrong and the function fails with an opaque "Failed to launch the browser process" that tells you almost nothing.
import chromium from '@sparticuz/chromium';
import puppeteer from 'puppeteer-core';
export const handler = async (event) => {
const browser = await puppeteer.launch({
args: chromium.args,
executablePath: await chromium.executablePath(),
headless: chromium.headless,
});
try {
const page = await browser.newPage();
await page.setContent(event.html, { waitUntil: 'networkidle0' });
const pdf = await page.pdf({
format: 'A4',
printBackground: true, // Chrome drops backgrounds in print by default
});
return {
statusCode: 200,
headers: { 'Content-Type': 'application/pdf' },
isBase64Encoded: true,
body: pdf.toString('base64'),
};
} finally {
await browser.close(); // always close, or you leak the process
}
};
Two lines earn their keep here. printBackground: true stops Chrome from stripping your background colors and images in print mode, which is the most common reason a Lambda PDF comes out looking washed out compared to the browser. And the finally block that closes the browser matters more on Lambda than anywhere else: if a warm container reuses the execution environment and you left a browser open, you leak processes and eventually the function starts failing.
Gotcha three: cold starts
Even with everything wired correctly, the first invocation on a cold container is slow. Lambda has to load your package, decompress the Chromium binary and boot the browser before it renders anything, and that can add several seconds to the first request. For a user waiting on a "Download PDF" button, a multi second cold start is a visibly bad experience.
The usual mitigations are provisioned concurrency, which keeps warm containers ready and costs money whether or not they are used, and periodic warming pings, which are a hack that reduces but does not eliminate the problem. Both add cost and operational fuss to what was supposed to be a cheap serverless function. Cold starts are not a bug you fix once; they are a standing property of running a heavy binary in an ephemeral environment.
Gotcha four: memory and timeouts
Headless Chrome is memory hungry. Give the function too little and it dies mid render with an out of memory error; on Lambda, memory and CPU are coupled, so under-provisioning also makes rendering slower. In practice a Puppeteer PDF function usually wants 1,024 MB or more, well above the default. A complex page can also blow the function timeout, so you raise that too. Each knob you turn upward pushes the cost of the "cheap" serverless function further up, and a big batch of PDFs multiplies it, because Lambda scales by running many concurrent copies of a heavyweight function rather than sharing one browser.
Gotcha five: fonts and rendering surprises
The stripped Lambda environment ships almost no fonts. Text that looked right locally can render in a fallback face or, worse, as empty boxes for any glyph the environment does not have, which is common for non Latin scripts and emoji. The fix is bundling the font files your documents need alongside the function, which is more weight to manage inside that same 250 MB limit. It is solvable, but it is one more thing to get right, and it usually surfaces only after the feature ships and someone generates a document in a language you did not test.
When Lambda plus Puppeteer is the right call
None of this means the approach is wrong. If you already run heavily on AWS, have the Lambda expertise in house, and PDF generation is spiky enough that paying only for actual invocations genuinely beats a always-on server, the serverless path can be the most cost effective option you have. Teams run it at real scale. The point is only that "just run Puppeteer on Lambda" is not a small task; it is owning a headless browser, plus the extra constraints of a serverless runtime layered on top.
When to stop owning the browser
Add up what you are actually maintaining: the Chromium layer and its updates, the cold start mitigations, the memory tuning, the font bundles, the launch flag breakage every few Chromium versions, and the error handling for renders that hang. That is a standing engineering commitment for a feature that, from the product's point of view, is just "turn this HTML into a PDF." For many teams the honest answer is to let someone else run the browser.
A rendering API is one HTTP call from the same Lambda, or from anywhere, with no Chromium in your package, no cold start you own, no memory to tune and no fonts to bundle. You send a URL or HTML and get a finished PDF back. Sitepdf renders in managed Chromium and, with archive=true, keeps a timestamped snapshot of every document, which is exactly the provenance a serverless function gives you no easy way to build. The full self host versus API tradeoff, including the cost math, is in the library versus API comparison, and the Puppeteer path specifically in the Puppeteer alternative page. The same size limits and cold starts show up on Vercel functions too, which the Next.js PDF generator page covers for that stack.
It is worth naming the deeper question too. A lot of the pain here comes from using a full browser to do one narrow thing. If what you actually need from headless Chrome on Lambda is not documents but data, pulling structured content out of pages, that is a different job with its own tools, and a purpose built web scraping API removes the same browser fleet for that use case. Either way, the browser fleet is rarely the thing your product should be in the business of running. For the Puppeteer and Playwright comparison behind all of this, see Playwright vs Puppeteer for PDF generation.
Written by the team building Sitepdf, an HTML to PDF API that archives every page it renders. The in-browser converter is free to try; early access locks the launch pricing.