If you are reading this you have probably already written the twelve
lines. They work. page.setContent(html),
page.pdf({ format: 'A4' }), a file comes out, and the ticket
closes. That part is genuinely easy, and any page telling you otherwise
is selling something.
So this is not an argument that rendering is hard. It is an inventory of the four things that cost you after the easy part, written so you can price them — followed by the honest list of cases where you should ignore us and keep the thing you have.
The four things that actually cost you
1. A browser binary, wherever this runs
puppeteer and puppeteer-core are not the same
decision. puppeteer downloads a matching Chrome for you on
install; puppeteer-core does not, and expects you to install
a browser and point executablePath at it. Teams pick
puppeteer-core to keep images small and then own a Chromium
install per environment for as long as the service lives.
Either way something has to put a browser on the machine, keep it there across base-image rebuilds, and update it when Chrome ships a security fix.
Serverless makes this concrete. The usual answer is
@sparticuz/chromium, and version 153.0.0 unpacks to
70,091,665 bytes — 66.8 MiB
(npm
registry metadata, read 2026-11-03).
Against AWS Lambda's documented limit of 250 MB on the unzipped contents of a deployment package, including layers and custom runtimes (Lambda quotas, read 2026-11-03), that is not fatal — but it is 27% of the budget spent before any of your own code, and it is why teams end up on container images or a layer instead.
A unit note, because this is a comparison people get wrong by 5%. That quotas page says in its own words that "the Lambda documentation, log messages, and console use the abbreviation MB (rather than MiB) to refer to 1,024 KB." So the 250 is mebibytes, and the figure above is mebibytes too. Compared properly, 66.84 of 250.
And check that number yourself rather than taking ours, because the obvious objection to it is a good one:
$ du -sb node_modules/@sparticuz/chromium
70091665
The objection is that this package ships the browser
brotli-compressed and expands it into /tmp at
runtime — so is 66.84 MiB really deployment weight, or is it a
/tmp figure against a different quota? It is deployment
weight. The compressed browser is a file inside the installed
package: bin/chromium.br, 67,016,039 bytes, alongside
swiftshader.tar.br, al2023.tar.br and
fonts.tar.br. Those bytes sit in your deployment package and
count against the 250. The /tmp expansion at runtime is a
second cost against a separate quota — /tmp
is 512 MB by default — not the same one twice.
The consequence people hit first is not the 250 at all.
Because the browser is already brotli-compressed —
chromium.br is 67.0 MB of the 70.1 — compressing the
package again buys almost nothing. Measured rather than assumed:
zip -r over the installed package gives
69,984,911 bytes, 0.15% smaller.
The other Lambda limit on that same quotas page is 50 MB zipped, when you upload through the Lambda API or SDKs. This package alone is about 16.7 MiB over it before any of your code, so the direct-upload path is closed from the start.
Be careful about what a layer does for you here, because it is easy to get backwards. A layer does not escape the 250 — the quota's own wording includes layers in it. Nor does it escape the 50 MB upload limit: a layer is itself a .zip published through the same API, so a layer this size has to come from S3 too. What a layer buys is separation and reuse across functions, not more room. The thing that actually moves you off both numbers is a container image, which has its own limit of 10 GB uncompressed — and buys that with cold start time.
None of that is difficult. All of it is yours.
2. The container, which will surprise you exactly once
Two lines you will end up with, and it is worth knowing why rather than copying them:
args: ['--disable-dev-shm-usage', '--no-sandbox']
--disable-dev-shm-usage exists because Chrome writes to
/dev/shm and Docker gives a container 64 MB of it by
default. Renders work fine in testing and then tabs start crashing under
real page weight. The flag moves that traffic to /tmp, and
the alternative fix is to give the container more shared memory.
--no-sandbox is the other one, and it deserves more
suspicion than it usually gets. It is there because Chrome's sandbox
needs privileges a hardened container does not grant. Turning it off
means a malicious page — or a template that interpolates untrusted HTML —
is running with your process's privileges. If your documents are built
from user-supplied content, that is a real risk you have accepted, not a
lint warning. Run it as a non-root user in an isolated container if you
keep it.
3. Concurrency, which is where the memory goes
A page is a tab. Ten concurrent renders is ten tabs, and Chrome's memory
is per-tab, not per-process-you-can-cap. This is why nearly every
production Puppeteer service acquires a worker pool with a
MAX_CONCURRENCY — not because somebody wanted an
abstraction, but because the third time it OOMed under a batch job
somebody added one.
The parts that stay hard after the pool exists:
- Queueing. A month-end invoice run arrives as a spike. The pool either queues it, which needs a queue, or drops it.
-
Zombie tabs. A page that never fires
loadholds a tab until something kills it. You need a timeout, and the timeout needs to close the page rather than just reject the promise. - Restarts. Long-running browser processes leak. Most teams end up recycling the browser every N renders, which is a workaround with a number in it that somebody guessed.
None of this is exotic. It is a week of work and then an ongoing tax.
4. The version treadmill, which is the one that gets skipped
puppeteer is at 25.16.0, published
2026-10-22. 19.0.0 shipped
2022-10-14
(npm
registry metadata, both read 2026-11-03). A service pinned to
^19 is carrying a browser engine from four years ago, and it
is carrying it into every environment it was deployed to.
That drift is invisible while it works. It shows up as a CSS feature the designer used that your renderer does not have, or a Chrome CVE you are exposed to because your Chromium is old, and it shows up long after whoever pinned it moved on.
This page is itself an example, which is the most honest thing
it can tell you about the treadmill. It was written on
2026-08-16 against puppeteer 25.7.0 and
@sparticuz/chromium 149.0.0. Eleven weeks later both
numbers were stale — nine minor versions of one and four majors of the
other — and nothing happened to make anyone notice. That is the tax,
and it is the same tax whether the thing drifting is a page or a
production renderer.
When you should keep running it yourself
Straightforwardly, and we would rather say this than have you find out later:
- Your layout has to live in your repository, versioned and code-reviewed alongside the code that fills it. This is a genuinely good reason and no hosted-template product solves it.
- The content cannot leave your estate. Health records, legal discovery, an air-gapped deployment, a customer contract that forbids sub-processors. Nothing about a cloud API is compatible with that, and no amount of encryption in transit changes the answer.
- Volume is low and steady. A few hundred documents a month on a box you already run, with no spikes, is a solved problem. Puppeteer plus cron is fine. Paying for it would be silly.
- You need the whole browser. Clicking through a login, waiting on a chart that renders from a websocket, scraping and rendering in one pass. That is automation, not templating, and a rendering API is the wrong shape entirely.
- You have already paid the cost. A working service with a pool, timeouts, monitoring and somebody who understands it is an asset. Migrating a working thing has a cost and no upside on its own.
When an API is the better trade
- Documents are a side effect of your product rather than the product — invoices, receipts, tickets, certificates, labels — and nobody on the team wants to own a browser to produce them.
- Spiky volume. Month-end, or a campaign, where the pool that is right for Tuesday is wrong for the 1st.
- Serverless, where the binary and the cold start are both fighting you.
- An agency or platform deploying the same thing repeatedly, where every client estate you ship it into is another Chromium somebody has to keep patched after the engagement ends.
What we do, concretely
iReceipt PRO takes a template id and a JSON object and returns file bytes — PDF, PNG, JPG or WebP (published API spec, read 2026-11-03). Templates are authored and stored on our side; you send data, not markup.
import { IReceiptPRO } from "@ireceipt.pro/js";
const irp = new IReceiptPRO(API_KEY);
const pdf = await irp.createPdfFromPublicTemplate(
"invoice_universal_vzrt6k1s",
{ invoice: { number: "13" } }
);
On the wire that is
POST https://api.ireceipt.pro/v1/pdf/public/{template} with a
bearer token and your values under variables. Two things
worth knowing before you integrate, because both are easy to miss —
the first is in the
published spec and on
the errors page, the second is read
from the published package:
-
Success is
201, not200. Code that checksstatus === 200will treat every successful render as a failure. -
The SDK makes up to five attempts, with a lengthening
backoff, but only on failures a retry could fix: transport errors (no
response at all), 5xx, and 408 and 429. A 401 or 403 is thrown on the
first attempt and never slept on, so a wrong key fails fast instead of
taking the full ladder. The four sleeps are
(attempt + 1) × 1000ms— 2s, 3s, 4s, 5s, fourteen seconds in total — and that is what a slow or failing render costs you. Wrapping the call in your own retry loop multiplies the fourteen.
The honest limits, in the same breath:
- Templates live with us. If yours must live in your repo, this is the wrong product and the section above says so.
- We are a sub-processor. If your compliance posture cannot take one, stop here.
- It is not a browser. No navigation, no interaction, no logging in to something and rendering what is behind it.
The question that actually decides it
Not cost, and not effort. It is this:
Does the layout have to be versioned with the code that fills it?
If yes, keep Puppeteer — that requirement outranks everything above, and every hour you spend on pools and pinning is buying something you actually need.
If no, the browser is infrastructure you are maintaining in order to turn a template and some JSON into a file, and that is a reasonable thing to stop maintaining.
A checklist, whichever way you go
Worth running against your current service today, because three of these routinely turn out to be missing:
- What version of Chrome is in production right now, and who updates it?
-
Is
--no-sandboxset, and does any user-supplied content reach the page? - What happens to a render that never finishes — is the tab closed, or just the promise rejected?
-
What is the concurrency limit, and what happens to request
limit + 1? - When the pool is exhausted at month-end, does the queue have a bound?
- Are the fonts in the container, or is production quietly falling back to a default nobody has looked at?