PDFPipe

Migrating from Playwright / Already HTML and CSS

Migrating off self-hosted Playwright, and the option name changes

The same shape of migration as Puppeteer, with a slightly different option vocabulary and the same absence of any CSS delta.

What ports and what does not

All of it, for the same reason as Puppeteer: the engine on the other side is the same kind of engine. Templates and stylesheets are unaffected. What you are giving up is a browser you were operating, and what you are taking on is a network dependency.

What the call becomes

page.setContent then page.pdf becomes a POST to /v1/pdf with html; page.goto then page.pdf becomes the same with url. The option names map in snake case: format, landscape, margin, scale, printBackground to print_background, pageRanges to page_ranges, preferCSSPageSize to prefer_css_page_size, and headerTemplate and footerTemplate to header_html and footer_html. Playwright's emulateMedia call becomes options.media, which takes print or screen and defaults to print. Playwright's own defaults are worth checking against these rather than assumed, because the two projects made different choices in a couple of places.

Side by side

The old shape and the new one, with the parts that have no counterpart called out in comments rather than quietly omitted.

js
// Before
const browser = await chromium.launch();
const page = await browser.newPage();
await page.setContent(html, { waitUntil: "networkidle" });
await page.emulateMedia({ media: "print" });
const pdf = await page.pdf({
  format: "A4",
  printBackground: true,
  preferCSSPageSize: true,
});
await browser.close();

// After
const res = await fetch("https://api.pdfpipe.xyz/v1/pdf", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.PDFPIPE_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    html,
    options: {
      format: "A4",
      print_background: true,       // the default here
      prefer_css_page_size: true,
      media: "print",               // also the default here
    },
  }),
});
const pdf = Buffer.from(await res.arrayBuffer());

// emulateMedia becomes options.media. Print is the default on this side,
// so an explicit call to emulate print media has no replacement: it is
// already what happens.

The CSS delta, in both directions

What the old engine accepted that a current one does not, and what it refused that a current one wants. The second direction is the one people forget, and it is where the value of the migration is.

  • None, for the same reason as Puppeteer.
  • The print_background default differs from Playwright's, so check any template that never set it.
  • The media default is print here. If your code was emulating screen media deliberately, that is now an explicit option rather than the absence of one.

What changes without anything erroring

The dangerous list. Each of these produces a different document and no diagnostic, so none of them are caught by a test that only checks the render succeeded.

  • Backgrounds, from the default difference.
  • Nothing else in the document, and a page count difference means something other than the engine moved.
  • Whatever your waitUntil condition was buying, since the readiness point is now decided on the other side of the call.

What to diff before cutting over

Page counts, expecting equality. Then render a document that uses backgrounds and one that uses screen-only styling, which are the two places the defaults differ.

The one thing that always breaks

The same thing as Puppeteer: nothing in the document and everything around it. Code that treated PDF generation as an in-process operation with no failure mode acquires a network dependency, and the retry, timeout and error handling that a network call needs has to be written rather than inherited.

Frequently asked

Do I have to rewrite my templates to leave Playwright?

No. The templates are HTML and CSS and they carry across. What needs attention is the delta above: the places the two engines disagree, and the configuration that used to live outside the document and now lives inside it, or the other way round.

Can I run both for a while?

Yes, and it is the safest way to do it. Put both behind one internal function that takes your data and returns bytes, switch on an environment variable, and run the new path on real traffic while the old one still serves. You get a diff on real documents rather than on fixtures, and you keep a way back that does not involve a deploy.

What about the documents already generated?

Nothing here changes them. They are files that already exist. What is worth deciding before the cutover is whether a regenerated document has to match the original byte for byte or merely say the same thing, because for anything with a legal or audit character the answer is usually to keep the original file rather than to be able to reproduce it.

Other migrations

The nearest neighbours first, then others that started from the same kind of tool, because the model matters more than the language.

Render one of your existing documents through the playground before changing any code. That comparison is the whole of the risk assessment for this migration.