Puppeteer Alternative for Screenshots: Code and Cost

A Puppeteer alternative for screenshots: measured Puppeteer timings, side-by-side code, a cost calculator, and the jobs where Puppeteer still wins.

By Simon Chiu · Updated

Disclosure: I build RenderScreenshot. Puppeteer is excellent software, and I list the cases where you should keep it.

The best Puppeteer alternative depends on why you use Puppeteer. If you drive the browser through logins and forms, look at Playwright or a hosted browser. If your Puppeteer code opens a page, waits and calls page.screenshot(), a screenshot API replaces all of it with one request, and you stop running Chrome. This page shows both versions of the same function, my measurements of what Puppeteer costs in time and memory, a calculator for total cost, and the jobs where Puppeteer is still the right tool. If you are new to the Puppeteer side, start with taking screenshots with Puppeteer; for hosted APIs in general, see the best screenshot API comparison.

Last verified: October 15, 2026.

The short answer

Keep Puppeteer if Switch the screenshots to an API if
Screenshots are one step in a longer automation The job is URL or HTML in, image or PDF out
Targets are on a private network or localhost Targets are public pages or HTML you send
You need extensions, custom Chrome flags or a pinned Chrome version You do not want to patch Chrome, fonts and Docker images
You already run browsers and have spare capacity Chrome is the only reason you run a heavy server
Your volume is so high that a server beats any per-shot price Your volume is spiky, and idle browsers waste money

What Puppeteer costs: measured

Method box. October 15, 2026. Apple M1 Pro, 32 GB, macOS 27.0.1, Node.js 22.18.0, Puppeteer 24.43.1 with its bundled Chrome 148. Target https://example.com/, 1280×800 PNG, waitUntil: 'load'. "Cold" launches a new browser, captures and closes it. "Warm" reuses one browser and opens a new page per capture. 10 runs each, nearest-rank percentiles. Memory: the sum of resident memory over Chrome's process tree, read with ps right after a capture.

Measure Result
Cold capture (launch + goto + screenshot + close) p50 1,029 ms, p95 1,628 ms
Warm capture (reused browser) p50 189 ms, p95 272 ms
PNG size 81,234 bytes
Chrome process tree, summed RSS 761 to 776 MB

Three conclusions, and their limits:

  1. Launching is most of the time for a simple page. Reusing the browser cut p50 by about 5x. Every production Puppeteer setup ends up with a browser pool for this reason.
  2. Memory is the sizing constraint. The RSS sum double-counts shared pages on macOS, so treat it as an upper bound. Even so, a few concurrent browsers fill a small server. Measure on your Linux image before you size anything.
  3. example.com is the easy case. Real pages add network time, web fonts, lazy images and third-party scripts. Our posts on timeouts and memory leaks and scaling Puppeteer cover what happens under load.

I have not yet run RenderScreenshot through the same method, so there is no API row in this table. The comparison script in the best screenshot API comparison measures any API from your own servers.

The same function, two ways

Before: Puppeteer

One shared browser, a page per job, a hard timeout, and finally so pages do not leak. This is the minimum I would run in production; it still has no queue, no retry, no cache and no font setup.

// diy-puppeteer.mjs: the self-hosted version. One shared browser, a page per job, hard timeouts.
// Node 22+, npm i puppeteer@24
import puppeteer from 'puppeteer';
import { writeFile } from 'node:fs/promises';

let browserPromise;
function getBrowser() {
  // Reuse one Chrome process; relaunch if it crashed or was closed.
  browserPromise ??= puppeteer.launch({ args: ['--disable-dev-shm-usage'] }).then((b) => {
    b.on('disconnected', () => { browserPromise = undefined; });
    return b;
  });
  return browserPromise;
}

export async function screenshot({ url, width = 1280, height = 800, fullPage = false, darkMode = false }) {
  const browser = await getBrowser();
  const page = await browser.newPage();
  try {
    await page.setViewport({ width, height });
    if (darkMode) await page.emulateMediaFeatures([{ name: 'prefers-color-scheme', value: 'dark' }]);
    await page.goto(url, { waitUntil: 'networkidle2', timeout: 30_000 });
    return Buffer.from(await page.screenshot({ type: 'png', fullPage }));
  } finally {
    await page.close(); // a leaked page is a leaked renderer process
  }
}

if (import.meta.url === `file://${process.argv[1]}`) {
  try {
    const image = await screenshot({ url: process.argv[2] ?? 'https://example.com/', darkMode: true });
    await writeFile('diy.png', image);
    console.log(`diy.png: ${image.length} bytes`);
  } finally {
    await (await getBrowser()).close();
  }
}

Tested on October 15, 2026 with Node.js 22.18.0 and Puppeteer 24.43.1 against https://example.com/: wrote a 1280×800 PNG of 81,234 bytes, exit code 0.

After: RenderScreenshot

Same signature, same options. No browser, no pool, no cleanup.

// screenshot.mjs: the same function on RenderScreenshot. Same signature, no browser to manage.
// Node 22+, npm i renderscreenshot
import { writeFile } from 'node:fs/promises';
import { Client, TakeOptions } from 'renderscreenshot';

const rs = new Client(process.env.RS_API_KEY);

export function screenshot({ url, width = 1280, height = 800, fullPage = false, darkMode = false }) {
  let o = TakeOptions.url(url).width(width).height(height).format('png').waitFor('networkidle');
  if (fullPage) o = o.fullPage();
  if (darkMode) o = o.darkMode();
  return rs.take(o);
}

if (import.meta.url === `file://${process.argv[1]}`) {
  const image = await screenshot({ url: process.argv[2] ?? 'https://example.com/', darkMode: true });
  await writeFile('api.png', image);
  console.log(`api.png: ${image.length} bytes`);
}

The other languages in your stack call the same API without Node or Chrome on the box:

# screenshot.py: the same capture from a Python service, no Chrome on the box.
# Python 3.9+. pip install git+https://github.com/Render-Screenshot/rs-python.git
import os
import sys

from renderscreenshot import Client, TakeOptions

rs = Client(os.environ['RS_API_KEY'])


def screenshot(url, width=1280, height=800, full_page=False, dark_mode=False):
    options = TakeOptions.url(url).width(width).height(height).format('png').wait_for('networkidle')
    if full_page:
        options = options.full_page()
    if dark_mode:
        options = options.dark_mode()
    return rs.take(options)


if __name__ == '__main__':
    image = screenshot(sys.argv[1] if len(sys.argv) > 1 else 'https://example.com/', dark_mode=True)
    with open('api.png', 'wb') as f:
        f.write(image)
    print(f'api.png: {len(image)} bytes')
# screenshot.rb: the same capture from a Ruby app, no Chrome on the box.
# Ruby 3.0+. gem install renderscreenshot
require 'renderscreenshot'

RS = RenderScreenshot.client(ENV.fetch('RS_API_KEY'))

def screenshot(url, width: 1280, height: 800, full_page: false, dark_mode: false)
  options = RenderScreenshot::TakeOptions.url(url).width(width).height(height).format('png').wait_for('networkidle')
  options = options.full_page if full_page
  options = options.dark_mode if dark_mode
  RS.take(options)
end

if $PROGRAM_NAME == __FILE__
  image = screenshot(ARGV[0] || 'https://example.com/', dark_mode: true)
  File.binwrite('api.png', image)
  puts "api.png: #{image.bytesize} bytes"
end

Tested on October 15, 2026 against a local mock of our API (endpoint, Bearer auth, body shape): Node.js 22.18.0 with renderscreenshot 1.1.0, Python 3.14 with the rs-python SDK 1.0.0, Ruby 4.0.7 with the renderscreenshot gem 1.0.0. SDK references: Node, Python, Ruby.

What you stop maintaining

The "before" function is 30 lines, but the work around it is larger. When the capture moves to an API, these jobs leave your backlog:

  • Chrome and Puppeteer upgrades. Each Puppeteer release pins a Chrome version; APIs change (page.waitForTimeout was removed in v22), and old code breaks on upgrade.
  • The Linux image. Chrome's shared libraries, a font set that covers emoji and CJK text, and --disable-dev-shm-usage or a bigger /dev/shm in Docker.
  • The pool. How many browsers per server, how many pages per browser, when to recycle a browser that has grown, what to do when one crashes mid-job.
  • Timeouts and zombies. A page that never reaches networkidle, a close() that never runs, a Chrome process that outlives its Node parent.
  • Caching and storage. Where the PNG goes, how long it lives, and how a public page links to it without exposing anything.

None of these is hard alone. Together they are the maintenance hours in the calculator below.

Puppeteer calls and their API parameters

Puppeteer RenderScreenshot (POST body)
page.setViewport({ width, height, deviceScaleFactor }) viewport.width, viewport.height, viewport.scale (1 to 3)
puppeteer.KnownDevices[...] + page.emulate() viewport.device (for example iphone_14_pro)
page.goto(url, { waitUntil, timeout }) wait.until (load, domcontentloaded, networkidle), wait.timeout (seconds, max 60)
page.waitForSelector(sel) wait.for_selector
new Promise(r => setTimeout(r, ms)) wait.delay (ms)
page.screenshot({ fullPage: true }) capture.mode: "full_page", capture.scroll.simulate for lazy images
elementHandle.screenshot() capture.mode: "element", capture.selector
page.screenshot({ clip }) capture.clip
page.screenshot({ type, quality, omitBackground }) output.format, output.quality, output.transparent
page.pdf({ format, landscape, printBackground }) output.format: "pdf", pdf.paper, pdf.landscape, pdf.background
page.emulateMediaFeatures([{ name: 'prefers-color-scheme', value: 'dark' }]) browser.dark_mode
page.emulateMediaType('print') browser.media: "print"
page.setUserAgent() browser.user_agent
page.emulateTimezone(), page.setGeolocation() browser.timezone, browser.geolocation
page.setCookie(), page.setExtraHTTPHeaders(), page.authenticate() network.cookies, network.headers, network.auth
page.addStyleTag(), page.evaluate() page.styles, page.scripts
page.click(sel) page.click
Request interception to block ads or requests block.ads, block.trackers, block.requests, block.resources
Custom cookie-banner removal code block.cookie_banners

The Puppeteer migration guide converts a longer script step by step, and the wait parameters page explains each wait option.

Total cost: a calculator

The bill for an API is one line. The bill for Puppeteer has two: the server, and the hours someone spends on Chrome upgrades, font bugs, crashes and on-call. This script compares them from your numbers. Every default is zero on purpose.

// tco.mjs: monthly cost of self-hosted Puppeteer vs an API, from your own inputs.
// Every number below is an INPUT you replace. Nothing here is a measured fact about your stack.
const inputs = {
  shotsPerMonth: 20_000,
  serverCostPerMonth: 0,       // the VM or container you run Chrome on
  engineerHourlyCost: 0,       // loaded cost of the person who maintains it
  maintenanceHoursPerMonth: 0, // upgrades, crash triage, font fixes, on-call
  apiPlanPricePerMonth: 0,     // the API plan that covers shotsPerMonth
};

export function tco(i) {
  const selfHosted = i.serverCostPerMonth + i.engineerHourlyCost * i.maintenanceHoursPerMonth;
  return {
    selfHosted,
    api: i.apiPlanPricePerMonth,
    selfHostedPer1000: (selfHosted / i.shotsPerMonth) * 1000,
    apiPer1000: (i.apiPlanPricePerMonth / i.shotsPerMonth) * 1000,
    // Hours of maintenance per month at which the two options cost the same:
    breakEvenHours: i.engineerHourlyCost > 0 ? (i.apiPlanPricePerMonth - i.serverCostPerMonth) / i.engineerHourlyCost : null,
  };
}

if (import.meta.url === `file://${process.argv[1]}`) {
  const args = Object.fromEntries(process.argv.slice(2).map((a) => a.split('=')).map(([k, v]) => [k, Number(v)]));
  console.log(tco({ ...inputs, ...args }));
}

Run it with your inputs:

node tco.mjs shotsPerMonth=20000 serverCostPerMonth=40 engineerHourlyCost=100 maintenanceHoursPerMonth=4 apiPlanPricePerMonth=40

With those example inputs (not measurements), self-hosting costs 440 a month against 40 for the API, and breakEvenHours is 0: at that server price, any maintenance time makes self-hosting the more expensive option. Put in your own server bill and an honest count of hours from your incident log and commit history. The answer changes a lot with volume: at very high volume, one well-run server per many thousands of shots beats every per-shot price on our pricing comparison.

Tested on October 15, 2026 with Node.js 22.18.0.

When Puppeteer wins

  • The screenshot is part of a workflow. Log in, open a menu, fill a form, then capture. An API takes cookies, headers and one click; it does not run your script.
  • The page is not public. Intranet tools, localhost during tests, staging behind a VPN. A hosted API cannot reach them.
  • You need control we do not expose: Chrome extensions, launch flags, a pinned Chrome version, a custom font set, CDP calls.
  • Volume is very high and steady. A busy browser pool on a server you already pay for has a very low cost per shot.
  • Data must not leave your network. Rendering in your own infrastructure keeps page content in-house.
  • You run tests. For visual regression in CI, keep the browser in the test runner, where Playwright's and Puppeteer's tooling lives.

If your Puppeteer code needs a browser but you are tired of hosting it, a hosted browser keeps your code: see the Browserless alternative and the Playwright alternative.

Try the "after" version

Create a free API key (150 credits a month, no credit card), set RS_API_KEY, and run the "after" function on the URLs your Puppeteer job captures today. Compare the images side by side before you delete any browser code.

Frequently asked questions

What is the best alternative to Puppeteer for screenshots?
If you only need images, a screenshot API such as RenderScreenshot replaces the browser code with one request. If you need full browser control, Playwright is the closest library alternative, and hosted browsers such as Browserless or Cloudflare Browser Rendering run Puppeteer for you.
How fast is Puppeteer at taking a screenshot?
On an Apple M1 Pro with Puppeteer 24.43.1, a capture of example.com took 1,029 ms at p50 when launching a new browser each time, and 189 ms at p50 with a reused browser (10 runs each, October 2026). Real pages take longer.
How much memory does Puppeteer use?
In our test the summed resident memory of the Chrome process tree was 761 to 776 MB on macOS after one capture. That sum counts shared memory more than once, so the real figure is lower; measure on your own Linux servers before you size them.
When should I keep Puppeteer?
Keep it when the screenshot is one step in a longer automation (logins, forms, scraping), when pages are on a private network, when you need a Chrome extension or flag, or when you run so many captures that a server is cheaper than any per-shot price.

Related in this topic

Try RenderScreenshot free

150 free credits every month, no credit card required.