PDFPipe

Migrating from Self-managed headless Chrome / Already HTML and CSS

Retiring your own headless browser fleet for a render API

A browser you built infrastructure around, where the document does not change at all and what you are deleting is a category of operational work.

What ports and what does not

The document, entirely. What you are migrating is everything you built around it: the container image with the shared libraries the browser needs, the process supervision, the memory limits, the concurrency cap, the zombie process reaper, the temporary directory sizing and the patching cadence for a browser that ships security updates continuously. None of that has an equivalent on the other side, because none of it is your problem afterwards.

What the call becomes

Whatever your service exposed becomes a POST to /v1/pdf. If you built an internal HTTP service around the browser, the migration is mostly a change of base URL and a change of option names, and the internal service can often stay in place as a thin adapter during the cutover, which is the safest way to do it. If you were driving the browser in-process, it is the Puppeteer or Playwright migration with your own wrapper in between.

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
// A cutover pattern: keep your internal interface, change what is
// behind it. This lets one call site change at a time and gives you a
// switch back.
async function renderPdf(html, opts = {}) {
  if (process.env.RENDER_BACKEND === "api") {
    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: toApiOptions(opts) }),
    });
    if (!res.ok) throw new RenderError(res.status, await res.text());
    return Buffer.from(await res.arrayBuffer());
  }
  return renderWithLocalBrowser(html, opts);   // the existing path
}

/* What gets deleted once the switch is flipped for good:
     the browser and its shared libraries from the image
     the process pool and its supervision
     the concurrency semaphore
     the zombie reaper
     the shared memory sizing for the container
     the browser patching cadence
   That list, not the call change, is the migration's value. */

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. It is the same kind of engine.
  • Check the defaults you had set explicitly in your wrapper, because those are the ones most likely to differ from the defaults on the other side.

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, if your wrapper relied on the browser default rather than setting print_background explicitly.
  • Concurrency behaviour. Your fleet had a fixed capacity and queued or failed beyond it; a hosted API has different limits and different behaviour at them, which is an operations change rather than a document one.
  • Cold start characteristics, which disappear as a category and are replaced by network latency.

What to diff before cutting over

Run both backends behind the same interface for a period and compare their outputs on live traffic rather than on fixtures. That is the advantage of the adapter pattern: you get a real diff on real documents before committing, and you keep a switch back.

The one thing that always breaks

Nothing in the document, and something in the deployment. The container image loses its browser and everything that referenced it, and the first thing to fail is usually a health check, a warm-up hook or a startup probe that was waiting for a browser to be ready. It is a small failure and it happens at the worst moment, which is the first deploy after the switch.

Frequently asked

Do I have to rewrite my templates to leave Self-managed headless Chrome?

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.