Guides / Puppeteer on serverless
Running Puppeteer on Vercel and Lambda
Your PDF route worked on your laptop. Then you deployed it, and got
Failed to launch the browser process, or a 250 MB bundle error, or a document whose text
turned into empty rectangles.
This page maps that terrain: what each wall actually is, the workaround that currently works for each, and when running your own browser stops paying for itself. It's written by the team behind a hosted PDF API, so treat the last two sections as interested. Everything before them is useful whether or not you ever buy anything.
Platform limits move. Every number here was checked in July 2026 against the vendor's own documentation or measured directly, and each is attributed so you can re-check it. If you're reading this much later, verify before you plan around it.
Why it breaks at all
Locally, npm install puppeteer downloads a complete Chrome build and launches it as a child
process. Both halves of that are hostile to a serverless function:
- A browser is enormous. Chrome for Testing's current stable build for Linux is a 184 MiB
zip for full Chrome and a 115 MiB zip for
chrome-headless-shell(measured againststorage.googleapis.com/chrome-for-testing-public, stable 151, July 2026). Those are the compressed downloads. Unpacked they are considerably larger, and the platform ceiling is measured unpacked. - The runtime is not a desktop. A Lambda-style container has no window system, no GPU, no
fonts to speak of, a read-only application directory, and a small writable
/tmp. Chromium expects a machine, and gets a slice of one.
Everything below follows from those two sentences.
Wall 1: the bundle ceiling
AWS Lambda: a zip-deployed function may be 50 MB zipped on direct upload, and 250 MB
unpacked including all layers. The unpacked limit is hard and cannot be raised. You've met it
if you've seen Unzipped size must be smaller than 262144000 bytes. Container images are
the escape hatch: those may be up to 10 GB.
Vercel Functions: 250 MB uncompressed, which Vercel's limits page explicitly attributes
to the same AWS ceiling. Since mid-2026 there is also a large functions path that raises it
to 5 GB. It requires fluid compute with Active CPU, is opt-in on existing projects via the
VERCEL_SUPPORT_LARGE_FUNCTIONS environment variable, and is not available with Secure Compute
or static IPs.
Most of the blog posts you'll find while debugging this predate that change and tell you the size wall is absolute. It isn't any more. It's just expensive: you're now shipping and cold-starting a few hundred megabytes of browser per function.
The standard fix is to stop bundling a browser you also can't run, and use a build compiled for the runtime:
npm install puppeteer-core @sparticuz/chromiumpuppeteer-core is Puppeteer without the browser download. @sparticuz/chromium is the
community-maintained serverless Chromium build, the successor to chrome-aws-lambda, whose last
release, 10.1.0, went out in July 2021 and pins a Chromium from that era. Stack Overflow
answers still point at it. Five years of Chromium releases have happened since.
The package's own documented usage is a puppeteer-core launch pointed at the binary it
decompresses:
// Source: the @sparticuz/chromium README. Verify against the version you install —
// the flag set and the headless mode have both changed across releases.
import puppeteer from 'puppeteer-core';
import chromium from '@sparticuz/chromium';
const browser = await puppeteer.launch({
args: await puppeteer.defaultArgs({ args: chromium.args, headless: 'shell' }),
executablePath: await chromium.executablePath(),
headless: 'shell',
});At 149.0.0 (published 27 May 2026) that package unpacks to about 66 MiB on npm, because the binary inside it is Brotli-compressed. It fits. Everything in the next four sections is the price of the fit.
The Vercel Edge runtime cannot run any of this. It's not Node with fewer APIs. It cannot execute a binary at all. A PDF route that launches a browser must be on the Node runtime.
Wall 2: the cold start you cannot cache
That 66 MB package is not a browser yet. On a cold invocation, executablePath() decompresses
chromium.br into /tmp/chromium, plus a fonts archive and the SwiftShader archive for software
graphics. Only then does Chromium launch, and only then does your render begin. With the
@sparticuz/chromium-min variant (the one you use when the binary won't fit in your bundle at
all), a download of the pack tarball you host yourself precedes all of that.
Warm invocations skip it: the extracted binary is still in /tmp, and the package checks there
first. So the cost is per cold container, which makes it easy to miss in testing and easy to hit
in production, where traffic is bursty and containers are recycled constantly.
Two things to plan for:
/tmpis 512 MB by default on Lambda (raisable to 10 GB, billed). A decompressed Chromium plus SwiftShader plus your own scratch files live there, and it's not infinite.- You pay for the decompression. Serverless billing is duration × provisioned memory, and those seconds are duration like any other. You're renting 1.5 GB of RAM to un-Brotli a browser, on every cold container, forever.
Wall 3: fonts, and the failure that doesn't fail
This is the wall that produces a support ticket rather than an error.
The Lambda runtime ships essentially no font faces. @sparticuz/chromium bundles Open Sans
(Latin, Greek, Cyrillic) so that something renders. Anything outside that (your brand typeface,
CJK, Arabic, Cyrillic beyond Open Sans's coverage, emoji) has no fallback on the machine, and
Chromium does what it always does with a missing glyph: it draws a box, and exits successfully.
Nothing throws. Your monitoring is green. The invoice you emailed a customer has ▯▯▯ where their company name should be.
To fix it you ship font files into a directory Chromium searches: /var/task/.fonts,
/var/task/fonts, /opt/fonts (a layer), or /tmp/fonts. Then you own font provisioning as
part of your deployment, forever, including for every locale you later sell into.
Test this deliberately. Render a document containing a customer name in a script your bundled fonts don't cover, on the deployed function, and look at the PDF. A local render won't reproduce it, because your laptop has fonts.
Wall 4: memory, and one browser per request
@sparticuz/chromium documents a 512 MB minimum and recommends 1600 MB or more. That's
not padding: Chromium's memory use scales with the complexity of what it renders, and a document
with large images or a long table can spike well past a small function's allocation. Out-of-memory
in a serverless function is a hard kill mid-render, with no exception to catch.
The recommended flag set is shaped by the same constraint, and shapes your reliability in turn:
--single-process, --no-zygote, --no-sandbox, --disable-setuid-sandbox. Single-process
Chromium means a renderer crash takes the whole browser down. And running with the sandbox off
is a real decision you should make consciously if any part of your template's content comes from
your users.
Then the structural cost: a serverless function handles one request per instance, so you launch a browser, render one document, and throw the browser away. There is no pool, no page reuse, no amortising a warm browser across requests. Every wall above is paid per document instead of per fleet.
Wall 5: version churn is now your maintenance
@sparticuz/chromium versions track Chromium (149.0.0 is Chromium 149) and deliberately do
not follow semver: its own documentation warns that breaking changes can land at the patch
level. puppeteer-core must be compatible with the Chromium it drives, so the pair has to be
upgraded together, and a mismatch shows up as Failed to launch the browser process or an opaque
protocol error, usually in a deploy that changed nothing else.
So the ongoing job you have taken on is: track a Chromium major every few weeks, upgrade both packages in lockstep, redeploy, and re-verify that your documents still look the same, because a Chromium upgrade is a rendering engine upgrade, and rendering engines change how they lay things out. If your documents are invoices, contracts, or anything a customer keeps, "looks the same after the upgrade" is a claim someone has to check.
Wall 6: the response cap nobody reads about
Vercel caps a function's request and response body at 4.5 MB. A PDF that exceeds it comes
back as FUNCTION_PAYLOAD_TOO_LARGE, no matter how well the render went.
A one-page invoice is nowhere near that. A 40-page report with product photos can be, and the fix is architectural rather than a flag: write the file to object storage from inside the function and return a URL, which means your PDF route now needs a bucket, a lifecycle policy, and signed-URL handling.
The workarounds, ranked honestly
Not all of these end at a hosted API. Two of them are genuinely the right answer.
puppeteer-core+@sparticuz/chromiumin the function. The default path, and it does work. You accept: cold-start decompression, font provisioning, ~1.6 GB allocations, the upgrade treadmill, one browser per request.- A Lambda container image. 10 GB instead of 250 MB, and you control the whole image, so fonts and the browser are installed the ordinary way, with a Dockerfile, instead of via a Brotli pack. Cold starts stay, but the packaging pain largely goes away. If you're committed to self-hosting on Lambda, this is the version to do.
- A long-running container (ECS, Fly, Railway, a VM) with a persistent browser. This is what a rendering service actually is: a browser that stays up, a fresh page per request, no cold start per document, and memory you can watch on a dashboard. At sustained volume this is the correct engineering answer, and it's also the point at which you have started operating a browser fleet: health checks, restarts on leak, concurrency limits, a font pipeline, an upgrade cadence, and a rendering-regression check.
- Hosting your own
chromium-minpack in S3. Solves a bundle-size number, and hands you a binary artifact to keep current. Rarely the best trade on its own. - Pinning
chrome-aws-lambdabecause a 2021 gist worked. No. Five years of unpatched Chromium, rendering behaviour that no longer matches any current browser, and every new answer you find will be about a different package.
When to stop
A clear-eyed test, no marketing in it. Keep running your own browser when:
- Rendering is your product, or so central that you want the whole stack in-house.
- You're already operating long-running containers, so a browser pool is one more service rather than a new competency.
- Volume is high and steady enough that a warm fleet is cheaper than per-document billing.
- What you render is not sensitive to Chromium's layout changing under you: screenshots and throwaway previews, not documents a customer keeps.
Stop when:
- The document is a side quest. You're billing customers, and PDF generation is a checkbox on that feature. But it has eaten a weekend, and it's the thing that pages you.
- The failure mode you can't accept is the silent one: a missing font, a Chromium upgrade that reflows a table, a template that renders differently than it did last month, with a green dashboard throughout.
- Nobody owns the Chromium upgrade cadence. Unowned upgrade cadences don't stay unowned. They become an incident.
The exit: one HTTP call
That's the case for handing the browser to someone whose job it is. PDFglyph keeps the template in a versioned store, renders it on a Chromium that is already running, and returns the bytes:
import { PdfGlyph } from 'pdfglyph';
const client = new PdfGlyph({ apiKey: process.env.PDFGLYPH_API_KEY! });
const { pdf } = await client.generate({
templateId: 'tpl_invoice_8x2k',
data: { customer: 'Northwind Ltd', number: 'INV-2048', total: '€1,240.00' },
});
Running exactly that against the live API on 31 July 2026, with the gallery's Modern Invoice template, returns:
{
bytes: 67084,
isUint8Array: true,
generationId: '4b059894-10ea-404b-a6cf-58bc5cd7881d',
durationMs: 58,
replayed: false
}A one-page A4 invoice, 67 KB, rendered in 58 ms of server time. Zero dependencies, no binary.
From the caller's side it's one POST, so it runs unchanged on Node, Vercel's Edge runtime,
Cloudflare Workers, Deno, and Bun. Point by point against the walls above:
- Bundle: the client is a few KB of JavaScript. Nothing to compress, nothing in
/tmp. - Cold starts: the browser is already up when your request arrives. The only cold start left is your function's own.
- Fonts: embed them in the template (see Fonts & images) and every render uses the same ones.
- Versions: templates are published versions you pin and roll back, and the preview renders under identical page conditions to production, so "did this change" is answerable before you ship it.
- Failures are loud: a failed generation returns a typed error naming the cause instead of a silently wrong document, and never consumes quota.
What you give up is honest too: your documents render on someone else's infrastructure, you're bounded by plan limits and request throughput, and the one endpoint is deliberately narrow. It renders a template with data and hands back bytes. There is no headless browser to script. If you need to crawl pages, fill forms, or take screenshots of sites you don't control, you need Puppeteer, and this page's first half is the honest version of that road.
Next steps
- Quickstart: an account, a key, and a rendered PDF in about five minutes.
- Generate PDF invoices in Next.js: the route handler, end to end, with the same walls absent.
- TypeScript SDK: typed errors, retries, idempotent replays.
- Going to production: keys, retries, monitoring, plans.