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.

ProblemWhat it looks likeHosted
Fonts and emojiTofu boxes in production that were fine locally; a Dockerfile that grows a fonts layerFonts are loaded from the page and waited for before printing
MemoryEach Chrome instance takes a few hundred megabytes; twenty concurrent renders at month end take the box downConcurrency is a plan setting; the browsers are not yours
Version pinningPuppeteer wants a specific Chromium; the container has another; an update breaks one of themNot your problem
Hung pagesA page that never fires load holds a worker forever without a timeout, and with one, a half-rendered PDFEvery render has a timeout and a status
Zombie processesChrome children that outlive the parent; disk filling with temp profilesNot your problem
Cold startsLaunching Chrome per render costs seconds; keeping one warm means managing its lifetimeBrowsers are kept warm and reused
RetriesA failed render needs a retry that does not double-bill or double-sendIdempotency keys and queued retries
OutputStoring files, expiring them, signing links for customersSigned URLs with retention by plan
RecordsWhich invoice failed, when, how long it took, how big it wasEvery job has a status, duration and size
BurstsOne box renders one invoice at a time well and a thousand on the first of the month badlyQueue 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.

Self-hosted Puppeteer
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
RenderBadger
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:

  1. Fonts are waited for before printing, so there is no document.fonts.ready to call. Web fonts referenced from the page load as usual.
  2. A render that may take longer than 30 seconds goes with async: true; you get a job id, then a webhook or GET /v1/jobs/:id. Async needs the Starter plan.
  3. Send an Idempotency-Key header 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

Pricing at 2,000 and 10,000 renders

Renders a monthSelf-hostedRenderBadger
2,000A VPS at 0 to 0, plus the setup and whoever is on callStarter, 9
10,000A larger box or two at 0 to 0, plus the same hours at higher stakesPro, 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.

Get a free API key