← All posts

Puppeteer to API Migration: A Step-by-Step Runbook

September 10, 2026

Most articles about ditching self-hosted Puppeteer or Playwright sell you on the idea and stop there. This one is an actual migration runbook: audit what your script does today, map each method call to an API parameter, validate that the new output matches the old, handle the flows that resist neat mapping, and cut over on your own schedule instead of gambling on a rewrite.

Why Teams Outgrow Self-Hosted Puppeteer and Playwright

A Puppeteer script that works perfectly on a laptop rarely survives production unchanged. Long-running Chrome instances leak memory as tabs accumulate detached DOM nodes and unclosed contexts — the classic puppeteer memory leak that eventually OOM-kills your container. Chrome updates on a schedule your team didn't choose, and a version bump can silently break waitForSelector timing or screenshot rendering. Add zombie processes from crashed pages, the need to autoscale a fleet of headless Chrome containers under load, and the cost of keeping Docker images patched, and you've got a full infrastructure project sitting on top of what was supposed to be a scraping script.

These puppeteer scaling problems don't show up in a demo, only under sustained traffic. The real self-hosted Puppeteer vs API tradeoff isn't "which tool renders a page better," but "who owns crash recovery, Chrome version drift, and capacity planning." If your team is spending sprint time on infrastructure instead of the data or documents the script was built to produce, migration is worth the few hours this runbook takes.

Step 1: Audit What Your Script Actually Does

Before touching a line of code, inventory the script's actual behavior. Most Puppeteer or Playwright scripts, however sprawling, boil down to a handful of repeated patterns. Log:

  • Every page.goto call, including navigation options like waitUntil, timeouts, and headers or cookies set beforehand.
  • Every wait condition — waitForSelector, waitForNavigation, waitForTimeout, or custom polling logic.
  • Every interaction — clicks, form fills, scrolls — and why it's there (dismiss a cookie banner? trigger lazy-loaded content?).
  • Every output — page.screenshot, page.pdf, page.content, or page.evaluate calls that extract structured data.
  • Any auth flow, multi-step navigation, or session/cookie reuse across requests.

This audit sounds tedious, but it's the step most failed migrations skip. You can't map what you haven't listed, and scripts that grew over months usually contain dead waits and redundant selectors nobody remembers adding. Trimming those out now makes the API version simpler than the original.

Step 2: Map Each Call to an API Parameter

With the inventory done, translate line by line. This is the core of puppeteer to API migration: most navigation, wait, and output logic has a direct equivalent.

Navigation + wait condition:

// Puppeteer
await page.goto(url, { waitUntil: 'networkidle0' });
await page.waitForSelector('.price');

becomes a single request with a target URL, a wait-until setting, and a selector-based wait condition — typically two or three request parameters instead of two sequential calls.

Screenshot:

await page.screenshot({ path: 'out.png', fullPage: true });

maps to a screenshot endpoint call with fullPage and format as parameters — no browser launch, no page object lifecycle to manage.

PDF:

await page.pdf({ format: 'A4', printBackground: true });

maps to a PDF endpoint with the same format and printBackground fields.

Extraction:

const data = await page.evaluate(() => document.querySelector('h1').innerText);

maps to a structured-data endpoint that accepts a selector or extraction script and returns parsed JSON instead of a live DOM handle.

This pattern holds whether you're moving from Puppeteer or Playwright — navigation, wait state, DOM query, and render output are the same concepts across both libraries, so a playwright-to-API migration follows this same table. For exact request shapes, field names, and defaults, keep the Docs · Browsevra page open while you do this mapping — it's faster than reverse-engineering parameters by trial and error.

Step 3: Run Both in Parallel and Diff the Output

Don't cut over on faith. Before retiring any script, run it side by side with the equivalent API call against the same URLs, then diff the results. For screenshots and PDFs, compare pixel or byte output with a tolerance threshold (fonts and anti-aliasing will never be byte-identical). For structured data, compare field values, not full JSON blobs, since key order and whitespace shouldn't count as failures.

A practical version: pick 50–100 representative URLs covering your edge cases (slow-loading pages, redirects, lazy content), run both the old script and the new API call against each, and log mismatches above your tolerance. This gives a concrete, numeric answer to "does the API return equivalent output?" instead of a gut feeling — and catches wait-condition mismatches before they reach production. Route a small percentage of live traffic through both paths if volume allows, and only move on once the mismatch rate is acceptable.

Step 4: Handle the Parts That Don't Map 1:1

Not everything is a clean parameter swap. Multi-step flows — logging in, clicking through a multi-page form, waiting on a JS-rendered modal — need explicit handling rather than a single goto call. A login flow migration typically means passing a sequence of actions (navigate, fill, click, wait) as a scripted step list rather than one static request; multi-step browser automation API support exists for exactly this case. Custom page.evaluate logic that manipulates the DOM before extraction generally maps to a custom script parameter rather than a built-in field.

Treat these as the minority of your Step 1 inventory, and check the docs for the specific action-sequence or custom-script parameters before assuming something isn't supported.

Step 5: Cut Over Without a Big-Bang Rewrite

Migrate by route or job type, not all at once. A safe rollout looks like this: pick the lowest-risk job (say, a scheduled screenshot job with no auth), feature-flag it to call the API instead of the local script, and monitor error rates and latency for a few days. Then move the next job type, checking the Pricing · Browsevra page against your actual call volume so cost stays predictable as traffic scales. Only after every job type is running on the API should you decommission the old infrastructure — pull down Docker images, remove the Chrome-launch code, and retire the crash-recovery scripts last, once nothing depends on them.

What Actually Changes After Migration

Engineering time shifts from keeping Chrome alive to using what it produces. You stop debugging zombie processes and Chrome version drift, and start spending that time on the screenshots, PDFs, or extracted data themselves. A cost comparison isn't a flat "cheaper" or "more expensive" — at low volume, a managed API usually costs less than the engineering hours spent babysitting infrastructure; at high volume, you trade some infrastructure cost for predictable benefits: no scaling code, no memory-leak firefighting, consistent Chrome versions. You give up some low-level control over the browser process, but for most teams that control was never the point — reliable output was.

Frequently Asked Questions

Why does a working Puppeteer/Playwright script become a maintenance burden at scale?

Production traffic exposes problems a local run never does: memory leaks from accumulated browser contexts, Chrome version drift breaking timing-sensitive waits, zombie processes from crashed pages, and the ongoing work of scaling a fleet of headless Chrome containers. None of this is visible in a demo — it only appears under sustained load.

What parts of a typical script map directly to API parameters?

Navigation and wait conditions (page.goto plus waitForSelector) map to a request's URL and wait-condition fields, page.screenshot maps to a screenshot endpoint, page.pdf maps to a PDF endpoint, and page.evaluate-based extraction maps to a structured-data endpoint. Most standard scripts convert with a straightforward one-to-one mapping.

How do you validate that the API output matches your existing script's output before cutting over?

Run both the old script and the new API call against the same batch of representative URLs, then diff results with a defined tolerance threshold — pixel comparison for screenshots and PDFs, field-level comparison for extracted data. Routing a slice of live traffic through both paths gives a numeric mismatch rate instead of a guess.

How should teams handle features that don't have a 1:1 API equivalent?

Multi-step flows like logins or multi-page forms typically need an action-sequence parameter (navigate, fill, click, wait) rather than a single request, and custom DOM manipulation before extraction usually maps to a custom-script parameter. These cases are normally a small minority of a script's total logic once audited.

What's a safe rollout sequence that avoids a risky big-bang cutover?

Migrate one route or job type at a time behind a feature flag, monitor error rates and cost against actual traffic, and only decommission the old infrastructure once every job type has been running successfully on the API. This avoids betting the entire pipeline on a single cutover event.

Ready to see it in practice? Keep your script's logic and URLs exactly as they are, and run your first call through browsevra — the Docs show the exact request shape for your first migrated endpoint, and Pricing lets you check the numbers against your real volume before you commit to anything.