Compared
Puppeteer as a service, for PDFs and screenshots
Running Puppeteer on your own server is the right call when renders are rare, internal, or part of something bigger than a render. It stops being the right call when the renders are tied to money or customers, because Chrome fails in the ways it fails exactly when the invoices go out. This page is the list of what you are actually running, and what the hosted version looks like.
Who this page is for
You have a puppeteer dependency, a Dockerfile with a long apt-get line, and a function that takes HTML and returns a PDF. It worked on the first day. Since then: Chrome ate the memory on the box, a render hung and took the worker with it, the fonts in the container were not the fonts on your laptop, and a Chromium update broke the version pin on a Friday. You are searching because the third one happened at month end.
What self-hosting actually costs
The VPS is the cheap part. This is the rest, each one a thing you have either already fixed or are about to.
| Problem | What it looks like | Hosted |
|---|---|---|
| Fonts and emoji | Tofu boxes in production that were fine locally; a Dockerfile that grows a fonts layer | Fonts are loaded from the page and waited for before printing |
| Memory | Each Chrome instance takes a few hundred megabytes; twenty concurrent renders at month end take the box down | Concurrency is a plan setting; the browsers are not yours |
| Version pinning | Puppeteer wants a specific Chromium; the container has another; an update breaks one of them | Not your problem |
| Hung pages | A page that never fires load holds a worker forever without a timeout, and with one, a half-rendered PDF | Every render has a timeout and a status |
| Zombie processes | Chrome children that outlive the parent; disk filling with temp profiles | Not your problem |
| Cold starts | Launching Chrome per render costs seconds; keeping one warm means managing its lifetime | Browsers are kept warm and reused |
| Retries | A failed render needs a retry that does not double-bill or double-send | Idempotency keys and queued retries |
| Output | Storing files, expiring them, signing links for customers | Signed URLs with retention by plan |
| Records | Which invoice failed, when, how long it took, how big it was | Every job has a status, duration and size |
| Bursts | One box renders one invoice at a time well and a thousand on the first of the month badly | Queue them with async and get a webhook each |
What the hosted version looks like
The options keep their Puppeteer names on purpose, so the switch is mostly deleting code.
import puppeteer from "puppeteer";
let browser; // kept warm, restarted when it dies
async function getBrowser() {
if (!browser || !browser.connected) {
browser = await puppeteer.launch({ args: ["--no-sandbox"] });
}
return browser;
}
export async function renderInvoice(html, footer) {
const b = await getBrowser();
const context = await b.createBrowserContext();
const page = await context.newPage();
page.setDefaultTimeout(30_000);
try {
await page.setContent(html, { waitUntil: "networkidle0" });
await page.evaluateHandle("document.fonts.ready");
return await page.pdf({
format: "A4",
printBackground: true,
margin: { top: "20mm", bottom: "20mm" },
displayHeaderFooter: true,
footerTemplate: footer,
});
} finally {
await context.close();
}
}
// plus: the Dockerfile, fonts, memory limits, the retry,
// storage, signed links, and a log of what failed
export async function renderInvoice(html, footer) {
const res = await fetch("https://renderbadger.sh/v1/pdf", {
method: "POST",
headers: {
authorization: `Bearer ${process.env.RENDERBADGER_KEY}`,
"content-type": "application/json",
},
body: JSON.stringify({
html,
format: "A4",
margin: { top: "20mm", bottom: "20mm" },
footerTemplate: footer,
output: "url",
}),
});
return res.json(); // { id, status: "done", url, expiresAt, durationMs }
}
Three things to know when you move:
- Fonts are waited for before printing, so there is no
document.fonts.readyto call. Web fonts referenced from the page load as usual. - A render that may take longer than 30 seconds goes with
async: true; you get a job id, then a webhook orGET /v1/jobs/:id. Async needs the Starter plan. - Send an
Idempotency-Keyheader from your own invoice id. A retry after a network error returns the same render instead of making a second one.
Keep self-hosting if
- Renders are rare. A few a day on an internal tool does not justify a vendor.
- The pages are on a private network the service cannot reach. We render what the public internet can see, or HTML you send.
- The render is one step in a longer browser flow: log in, click, scrape, then capture. That is automation, not rendering, and Puppeteer is the right tool for it.
- You need PDF/A, tagged PDF or a typesetting engine. Chrome does not do those, hosted or not.
- Compliance requires the bytes never leave your infrastructure.
Pricing at 2,000 and 10,000 renders
| Renders a month | Self-hosted | RenderBadger |
|---|---|---|
| 2,000 | A VPS at 0 to 0, plus the setup and whoever is on call | Starter, 9 |
| 10,000 | A larger box or two at 0 to 0, plus the same hours at higher stakes | Pro, 9 |
On the server line alone it is close. The difference is the second column's "plus": the hours in the table above, paid once to build and again every time Chrome finds a new way to fail. If those hours are cheap for you, self-host. If they come out of the week you meant to spend on the product, they are the most expensive line on the page.
Prices checked October 26, 2026. In US dollars.
Try it on your own invoice
The free plan is 100 renders a month, no card, and the key is live the moment you create it.