To migrate from Puppeteer to an API, take one working capture script, map each Puppeteer call to a field in a RenderScreenshot request body, and delete the browser code. A script of 30 lines usually becomes one POST /v1/screenshot with a JSON body of 15 lines. This guide converts a realistic script step by step, then lists what does not translate, so you can decide per job instead of per codebase. For the build-versus-buy decision, measured Puppeteer costs and the cases where Puppeteer is the better tool, read the Puppeteer alternative page; the alternatives hub lists the rest.

Tested on October 15, 2026, on macOS with Node.js 22.18.0: the Puppeteer script with [email protected] against https://example.com/, and the API version and SDK examples against a local mock of the RenderScreenshot API.

The script we will convert

This is the shape of most production capture code: a session cookie, a retina viewport, dark mode, a language header, blocked analytics, a wait for content, CSS to remove overlays, a short settle pause, and a full-page JPEG.

// before-puppeteer.mjs: a typical production capture script. Node 22+, npm i [email protected]
import puppeteer from 'puppeteer';

const target = process.argv[2] ?? 'https://example.com/';
const browser = await puppeteer.launch();
try {
  await browser.setCookie({ name: 'session', value: process.env.SESSION ?? 'demo', domain: new URL(target).hostname, secure: true, httpOnly: true });
  const page = await browser.newPage();
  await page.setViewport({ width: 1280, height: 800, deviceScaleFactor: 2 });
  await page.emulateMediaFeatures([{ name: 'prefers-color-scheme', value: 'dark' }]);
  await page.setExtraHTTPHeaders({ 'Accept-Language': 'en-GB' });

  await page.setRequestInterception(true);
  page.on('request', (req) => {
    if (/doubleclick\.net|google-analytics\.com/.test(req.url())) req.abort();
    else req.continue();
  });

  await page.goto(target, { waitUntil: 'networkidle0', timeout: 30_000 });
  await page.waitForSelector('main, p', { visible: true });
  await page.addStyleTag({ content: '#cookie-banner, .newsletter-modal { display: none !important; }' });
  await new Promise((r) => setTimeout(r, 500)); // let late animations settle

  await page.screenshot({ path: 'before.jpg', type: 'jpeg', quality: 80, fullPage: true });
  console.log('saved before.jpg');
} finally {
  await browser.close();
}

On the test machine it saved a 2,560 × 1,600 JPEG of 161,372 bytes; the whole run, including the Chrome launch, took 2.5 s of wall time.

A note from the test run: example.com no longer has an <h1>. The first version of this script waited for h1 and timed out after 30 seconds. Wait for an element you control, and check the selector against today's page, not the page you remember.

Line by line

Puppeteer RenderScreenshot body Notes
browser.setCookie({ name, value, domain, secure, httpOnly }) network.cookies: [{ name, value, domain, secure, http_only }] page.setCookie is deprecated in Puppeteer 24; the API field names use snake case
page.setViewport({ width, height, deviceScaleFactor: 2 }) viewport: { width, height, scale: 2 } scale is 1 to 3
page.emulateMediaFeatures([{ name: 'prefers-color-scheme', value: 'dark' }]) browser.dark_mode: true
page.setExtraHTTPHeaders({ 'Accept-Language': 'en-GB' }) network.headers: { "Accept-Language": "en-GB" }
page.setRequestInterception(true) + req.abort() by URL block.requests: ["*doubleclick.net*", ...] Glob patterns; block.ads and block.trackers cover the common networks
page.goto(url, { waitUntil: 'networkidle0', timeout: 30_000 }) wait: { until: "networkidle", timeout: 30 } Timeout in seconds, 60 at most
page.waitForSelector('main, p', { visible: true }) wait.for_selector: "main, p" for_selector_mode defaults to visible
page.addStyleTag({ content: '... { display: none }' }) page.remove: ["#cookie-banner", ".newsletter-modal"] Or page.styles for any other CSS
new Promise((r) => setTimeout(r, 500)) wait.delay: 500 Milliseconds, capped at 10,000. Runs right after load, before page changes; to wait after a click, see customize the page
page.screenshot({ type: 'jpeg', quality: 80, fullPage: true }) output: { format: "jpeg", quality: 80 }, capture.mode: "full_page" Add capture.scroll.simulate for lazy images

The converted request

// after-api.mjs: the same capture as before-puppeteer.mjs, as one RenderScreenshot request. Node 22+.
import { writeFile } from 'node:fs/promises';

const API = process.env.RS_API_BASE ?? 'https://api.renderscreenshot.com';
const target = process.argv[2] ?? 'https://example.com/';

const body = {
  url: target,
  viewport: { width: 1280, height: 800, scale: 2 },
  browser: { dark_mode: true },
  network: {
    cookies: [{ name: 'session', value: process.env.SESSION ?? 'demo', domain: new URL(target).hostname, secure: true, http_only: true }],
    headers: { 'Accept-Language': 'en-GB' },
  },
  block: { requests: ['*doubleclick.net*', '*google-analytics.com*'] },
  wait: { until: 'networkidle', for_selector: 'main, p', delay: 500, timeout: 30 },
  page: { remove: ['#cookie-banner', '.newsletter-modal'] },
  capture: { mode: 'full_page' },
  output: { format: 'jpeg', quality: 80 },
};

const res = await fetch(`${API}/v1/screenshot`, {
  method: 'POST',
  headers: { Authorization: `Bearer ${process.env.RS_API_KEY}`, 'Content-Type': 'application/json' },
  body: JSON.stringify(body),
  signal: AbortSignal.timeout(65_000),
});
if (!res.ok) throw new Error(`RenderScreenshot ${res.status}: ${await res.text()}`);
await writeFile('after.jpg', Buffer.from(await res.arrayBuffer()));
console.log(`saved after.jpg (${res.headers.get('x-credits-used')} credit)`);

Against the mock API it printed saved after.jpg (1 credit). The same body works from any language; here it is with curl:

curl -X POST https://api.renderscreenshot.com/v1/screenshot \
  -H "Authorization: Bearer $RS_API_KEY" -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/",
    "viewport": { "width": 1280, "height": 800, "scale": 2 },
    "browser": { "dark_mode": true },
    "block": { "requests": ["*doubleclick.net*", "*google-analytics.com*"] },
    "wait": { "until": "networkidle", "for_selector": "main, p", "delay": 500, "timeout": 30 },
    "page": { "remove": ["#cookie-banner", ".newsletter-modal"] },
    "capture": { "mode": "full_page" },
    "output": { "format": "jpeg", "quality": 80 }
  }' --output after.jpg

Simple captures fit in a GET URL, which is handy for a first comparison in the browser:

curl "https://api.renderscreenshot.com/v1/screenshot?url=https%3A%2F%2Fexample.com%2F&width=1280&height=800&scale=2&dark_mode=true&full_page=true&format=jpeg&quality=80&delay=500" \
  -H "Authorization: Bearer $RS_API_KEY" --output after-get.jpg

SDK examples

The same capture with the official SDKs. page.remove has no builder method, so send it in the raw POST body if you need it.

Node.js

import { Client, TakeOptions } from 'renderscreenshot';

const client = new Client(process.env.RS_API_KEY);
const image = await client.take(
  TakeOptions.url('https://example.com/').width(1280).height(800).scale(2).darkMode()
    .cookies([{ name: 'session', value: process.env.SESSION, domain: 'example.com' }])
    .headers({ 'Accept-Language': 'en-GB' })
    .blockUrls(['*doubleclick.net*', '*google-analytics.com*'])
    .waitFor('networkidle').waitForSelector('main, p').delay(500)
    .fullPage().format('jpeg').quality(80)
);

Python

image = client.take(
    TakeOptions.url('https://example.com/').width(1280).height(800).scale(2).dark_mode()
    .cookies([{'name': 'session', 'value': os.environ['SESSION'], 'domain': 'example.com'}])
    .headers({'Accept-Language': 'en-GB'})
    .block_urls(['*doubleclick.net*', '*google-analytics.com*'])
    .wait_for('networkidle').wait_for_selector('main, p').delay(500)
    .full_page().format('jpeg').quality(80)
)

Ruby

image = client.take(
  RenderScreenshot::TakeOptions.url('https://example.com/').width(1280).height(800).scale(2).dark_mode
    .cookies([{ name: 'session', value: ENV.fetch('SESSION'), domain: 'example.com' }])
    .headers({ 'Accept-Language' => 'en-GB' })
    .block_urls(['*doubleclick.net*', '*google-analytics.com*'])
    .wait_for('networkidle').wait_for_selector('main, p').delay(500)
    .full_page.format('jpeg').quality(80)
)

PHP

$image = $client->take(
    TakeOptions::url('https://example.com/')->width(1280)->height(800)->scale(2)->darkMode()
        ->cookies([['name' => 'session', 'value' => getenv('SESSION'), 'domain' => 'example.com']])
        ->headers(['Accept-Language' => 'en-GB'])
        ->blockUrls(['*doubleclick.net*', '*google-analytics.com*'])
        ->waitFor('networkidle')->waitForSelector('main, p')->delay(500)
        ->fullPage()->format('jpeg')->quality(80)
);

Java

The Java SDK reference documents no cookie method. This version covers everything else; send network.cookies in a raw POST body if the page needs the session.

byte[] image = client.take(
    TakeOptions.url("https://example.com/").width(1280).height(800).scale(2.0).darkMode()
        .headers(Map.of("Accept-Language", "en-GB"))
        .blockUrls(Arrays.asList("*doubleclick.net*", "*google-analytics.com*"))
        .waitFor("networkidle").waitForSelector("main, p").delay(500)
        .fullPage().format("jpeg").quality(80)
);

Go

image, err := client.Take(ctx, rs.URL("https://example.com/").Width(1280).Height(800).Scale(2).DarkMode().
    Cookies([]rs.Cookie{{Name: "session", Value: os.Getenv("SESSION"), Domain: "example.com"}}).
    Headers(map[string]string{"Accept-Language": "en-GB"}).
    BlockURLs([]string{"*doubleclick.net*", "*google-analytics.com*"}).
    WaitFor(rs.WaitNetworkIdle).WaitForSelector("main, p").Delay(500).
    FullPage().Format(rs.FormatJPEG).Quality(80))

Node.js, Python, Ruby, PHP and Go ran against the mock API. The Java example was not run.

What does not translate

Be honest with yourself about these before you plan the migration. Each one is a reason to keep Puppeteer for that job:

  • Multi-step flows. Typing into a login form, clicking through a wizard, paginating. The API loads one URL. For logins, sign in over HTTP and pass the cookie; the authenticated pages guide has a tested script.
  • Reading data back. page.evaluate() that returns values to your code has no equivalent. page.scripts runs your JavaScript in the page, but its return value goes nowhere.
  • page.waitForFunction(). There is no custom wait condition. Make the page add an element when it is ready (for example <div data-ready>), and wait for it with wait.for_selector.
  • Changing responses. Request interception that rewrites or mocks responses. The API can block requests, not edit them.
  • Pages on a private network. The renderer runs in the cloud and cannot reach localhost or your VPN. Expose a preview URL, or send the markup with html.
  • More than one capture per page load. Each capture is one request and one credit.

Roll out behind a flag

  1. Wrap your capture function so it can call either backend, controlled by an environment variable.
  2. Run both on a sample of real URLs and compare dimensions and a few images by eye. With scale: 2, a viewport capture of 1,280 × 800 should be 2,560 × 1,600 on both sides.
  3. Watch the error rate. A target_error with retryable: true is worth one retry; a validation_error means the body is wrong. Failed renders do not cost credits.
  4. Switch the flag, then remove Chrome from your image: the browser binary, its system libraries, font packages, the browser pool and any zombie-process cleanup. Our posts on Puppeteer timeouts and memory leaks and scaling Puppeteer list what that code usually contains.

Troubleshooting

Symptom Likely cause Fix
Capture fires before data appears networkidle2 habits; long-polling keeps the network busy Use wait.for_selector on the element that holds the data
timeout error after 30 s Selector never matches the live page Check the selector in the browser console on the real URL
Image is half the expected size deviceScaleFactor not carried over Set viewport.scale
Overlay still visible The overlay's selector changed, or it lives inside an iframe Check the live page for the current selector; selectors do not reach into cross-origin iframes
Requests you blocked still load Glob does not match the full URL Wrap the host in *, for example *doubleclick.net*
Logged-in page shows the login screen Cookie domain does not match the URL host Use the exact host, or the parent domain the app sets
PDF looks different from page.pdf() Different defaults Set pdf.paper, pdf.margin and pdf.print_background explicitly; see PDF options
Long delays are cut short wait.delay is capped at 10,000 ms Wait for a selector instead of time

Related in this topic

Was this page helpful?