Home / Blog / Puppeteer or a rendering API

Running Puppeteer yourself, or calling an API

The twelve lines are the cheap part and everybody knows it. Here is an inventory of what you have agreed to own, so you can price it and then decide — including the cases where the answer is to keep what you have.

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:

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:

When an API is the better trade

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:

The honest limits, in the same breath:

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:

  1. What version of Chrome is in production right now, and who updates it?
  2. Is --no-sandbox set, and does any user-supplied content reach the page?
  3. What happens to a render that never finishes — is the tab closed, or just the promise rejected?
  4. What is the concurrency limit, and what happens to request limit + 1?
  5. When the pool is exhausted at month-end, does the queue have a bound?
  6. Are the fonts in the container, or is production quietly falling back to a default nobody has looked at?

The one-line summary

The code is the cheap part. You are deciding whether to own a browser binary, a container's shared memory, a concurrency model and a version treadmill — and whether that is worth it turns on one question about where your templates have to live, not on a comparison table.

Try the five-minute quickstart · What the API returns when it is not returning a file · Authentication: where the key goes, and what each failure means

Nixie · [email protected]
Updated Nov 8, 2026. AI tools were used in preparing this article.