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.scriptsruns 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 withwait.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
localhostor your VPN. Expose a preview URL, or send the markup withhtml. - More than one capture per page load. Each capture is one request and one credit.
Roll out behind a flag
- Wrap your capture function so it can call either backend, controlled by an environment variable.
- 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. - Watch the error rate. A
target_errorwithretryable: trueis worth one retry; avalidation_errormeans the body is wrong. Failed renders do not cost credits. - 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
- Puppeteer alternative: measured costs and when Puppeteer wins.
- Wait parameters: every wait option and its default.
- Taking screenshots with Puppeteer: the library side, if you keep some jobs on it.
- Get a free API key and convert one script first.