Playwright Alternative for Screenshots (Node and Python)

A Playwright alternative for production screenshots: measured Playwright timings, Node and Python code before and after, and why to keep it for tests.

By Simon Chiu · Updated

Disclosure: I build RenderScreenshot. Playwright is the tool I would pick for browser tests, and this page says so.

Playwright is two things for most teams: a test runner with visual comparisons, and a browser library that production code uses to make screenshots and PDFs. A Playwright alternative only makes sense for the second job. If your service launches Chromium to make thumbnails, social cards or archives, a screenshot API removes Chromium from that service. If Playwright runs your end-to-end tests, keep it. This page measures what Playwright costs per capture, shows the same function before and after in Node and Python, maps Playwright's context and page options to API parameters, and draws the line between the two jobs. For the library itself, see taking screenshots with Playwright; for hosted APIs, the best screenshot API comparison.

Last verified: October 15, 2026.

The short answer

Keep Playwright if Use a screenshot API if
The screenshots are test assertions (toHaveScreenshot) in CI The screenshots are product output: thumbnails, previews, OG images, PDFs
You need Firefox or WebKit rendering Chromium output is what you need
You script logins, clicks and multi-page flows One capture per URL or HTML document is the job
Pages are on localhost, staging or a private network Pages are public, or you send the HTML
You already run the browsers and they are not a problem Chromium and its dependencies are the heaviest thing in the service

What Playwright costs: measured

Method box. October 15, 2026. Apple M1 Pro, macOS 27.0.1, Node.js 22.18.0, Playwright 1.64.1 with Chromium 156. Target https://example.com/, 1280×800 PNG, waitUntil: 'load'. "Cold" launches Chromium, opens a context and page, captures and closes. "Warm" reuses one browser and creates a new context per capture, the isolation Playwright recommends. 10 runs each, nearest-rank percentiles.

Measure Result
Cold capture p50 499 ms, p95 533 ms
Warm capture (reused browser, new context) p50 262 ms, p95 297 ms
PNG size 80,922 bytes

For comparison, the same method with Puppeteer 24.43.1 on the same machine gave a cold p50 of 1,029 ms and a warm p50 of 189 ms (Puppeteer alternative). Playwright's default headless mode uses Chromium's lighter headless shell, while Puppeteer 24 runs full Chrome in its new headless mode, which likely explains most of the cold-start gap. Playwright's warm captures were slower because each one creates a fresh context; that is the price of isolation between jobs, and usually worth it.

These are local numbers with no network hop between your code and the browser, on the easiest page there is. They are a floor for self-hosting, not a head-to-head with an API. I have not run RenderScreenshot through this method yet.

Before and after: Node.js

Before: Playwright

One browser for the process and a context per job. deviceScaleFactor and colorScheme go on the context, not the page; putting them on page.setViewportSize() silently does nothing for the scale.

// diy-playwright.mjs: self-hosted capture with Playwright. One browser, one context per job.
// Node 22+, npm i playwright@1 && npx playwright install chromium
import { chromium } from 'playwright';
import { writeFile } from 'node:fs/promises';

const browser = await chromium.launch();

export async function screenshot({ url, width = 1280, height = 800, scale = 1, fullPage = false, darkMode = false }) {
  // deviceScaleFactor and colorScheme belong to the context, not the page.
  const context = await browser.newContext({
    viewport: { width, height },
    deviceScaleFactor: scale,
    colorScheme: darkMode ? 'dark' : 'light',
  });
  try {
    const page = await context.newPage();
    await page.goto(url, { waitUntil: 'load', timeout: 30_000 });
    await page.locator('body').waitFor(); // swap in a locator for the element that proves your page is ready
    return await page.screenshot({ type: 'png', fullPage });
  } finally {
    await context.close();
  }
}

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

Tested on October 15, 2026 with Node.js 22.18.0 and Playwright 1.64.1 against https://example.com/: wrote a 2560×1600 PNG (scale 2) of 188,682 bytes, exit code 0.

After: RenderScreenshot

// screenshot.mjs: the Playwright function above, as one API call. 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, scale = 1, fullPage = false, darkMode = false }) {
  let o = TakeOptions.url(url).width(width).height(height).scale(scale).format('png');
  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/', scale: 2 });
  await writeFile('api.png', image);
  console.log(`api.png: ${image.length} bytes`);
}

Before and after: Python

Playwright for Python is a separate install (pip install playwright, then python -m playwright install chromium), and the sync and async APIs differ. In a web app the browser also has to live somewhere: a module global, an app-level startup hook or a worker process, each with its own lifecycle bugs. The API version is a plain function call.

Before: Playwright for Python (sync API)

# diy_playwright.py: self-hosted capture with Playwright for Python (sync API).
# pip install playwright && python -m playwright install chromium
import sys

from playwright.sync_api import sync_playwright


def screenshot(browser, url, width=1280, height=800, scale=1, full_page=False, dark_mode=False):
    context = browser.new_context(
        viewport={'width': width, 'height': height},
        device_scale_factor=scale,
        color_scheme='dark' if dark_mode else 'light',
    )
    try:
        page = context.new_page()
        page.goto(url, wait_until='load', timeout=30_000)
        page.locator('body').wait_for()
        return page.screenshot(type='png', full_page=full_page)
    finally:
        context.close()


if __name__ == '__main__':
    with sync_playwright() as p:
        browser = p.chromium.launch()
        try:
            image = screenshot(browser, sys.argv[1] if len(sys.argv) > 1 else 'https://example.com/', scale=2)
        finally:
            browser.close()
    with open('diy.png', 'wb') as f:
        f.write(image)
    print(f'diy.png: {len(image)} bytes')

Tested on October 15, 2026 with Python 3.14 and Playwright for Python 1.64.0 against https://example.com/: wrote a 2560×1600 PNG of 188,682 bytes.

After: RenderScreenshot

# screenshot.py: the Playwright function above, as one API call.
# 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, scale=1, full_page=False, dark_mode=False):
    options = TakeOptions.url(url).width(width).height(height).scale(scale).format('png')
    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/', scale=2)
    with open('api.png', 'wb') as f:
        f.write(image)
    print(f'api.png: {len(image)} bytes')

The SDK call is synchronous. In an async framework (FastAPI, aiohttp), run it in a thread with await asyncio.to_thread(screenshot, url) so it does not block the event loop.

Ruby, for completeness

Playwright has no official Ruby binding, so Ruby teams usually shell out to Node or run a sidecar. The API needs neither:

# screenshot.rb: the same capture from Ruby. Ruby 3.0+. gem install renderscreenshot
require 'renderscreenshot'

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

def screenshot(url, width: 1280, height: 800, scale: 1, full_page: false, dark_mode: false)
  options = RenderScreenshot::TakeOptions.url(url).width(width).height(height).scale(scale).format('png')
  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/', scale: 2)
  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.

Playwright options and their API parameters

Playwright RenderScreenshot (POST body)
browser.newContext({ viewport }) viewport.width, viewport.height
newContext({ deviceScaleFactor }) viewport.scale (1 to 3)
newContext({ isMobile, hasTouch }), devices['iPhone 14 Pro'] viewport.mobile, viewport.touch, viewport.device
newContext({ colorScheme: 'dark' }) browser.dark_mode
newContext({ reducedMotion: 'reduce' }) browser.reduced_motion
newContext({ locale, timezoneId, geolocation }) browser.locale, browser.timezone, browser.geolocation
newContext({ userAgent }) browser.user_agent
newContext({ httpCredentials }) network.auth (type: "basic")
newContext({ extraHTTPHeaders }), context.addCookies() network.headers, network.cookies
`page.goto(url, { waitUntil: 'load' \ 'domcontentloaded' \
locator.waitFor() wait.for_selector, wait.for_selector_mode
page.screenshot({ fullPage, clip, type, quality, omitBackground }) capture.mode, capture.clip, output.format, output.quality, output.transparent
locator.screenshot() capture.mode: "element", capture.selector
page.screenshot({ mask }) page.hide (hides elements instead of painting a box)
page.emulateMedia({ media: 'print' }) browser.media: "print"
page.pdf({ format, landscape, printBackground }) output.format: "pdf", pdf.paper, pdf.landscape, pdf.background
page.addInitScript(), page.addStyleTag() page.scripts, page.styles
page.route() to abort requests block.requests, block.resources, block.ads, block.trackers

Playwright's page.screenshot() writes PNG or JPEG only. The API adds WebP and AVIF (output.format) if smaller files matter. The full list is in the parameter docs.

Where Playwright stays: your tests

Draw the line by who looks at the image:

  • A test runner looks at it. Keep Playwright. expect(page).toHaveScreenshot() with stored baselines, traces and the HTML report are built for this, run next to your app in CI, and reach localhost. An external API cannot see your dev server and adds network variance to a pixel diff.
  • A user or a crawler looks at it. That is product output. It needs caching, a stable public URL, retries and a bill you can predict, which is what a screenshot API is for.

Many teams end up with both: Playwright in the test suite, an API in the product. That is fine. The mistake is running a Chromium fleet in production only because the test suite already depends on Playwright.

When Playwright is the better choice in production too

  • Multi-step flows: log in, pick a date range, then capture a dashboard.
  • Cross-browser output: you need WebKit or Firefox rendering.
  • Private targets: intranets, preview environments behind auth you cannot pass as cookies or headers.
  • Very high, steady volume on servers you already run.
  • Hosted, but your code: if you want Playwright without hosting it, a hosted browser keeps your scripts. See the Browserless alternative or Playwright on Cloudflare Browser Rendering.

Try the "after" version

Get a free API key (150 credits a month, no credit card), set RS_API_KEY, and call the "after" function with the same URLs and scale your Playwright job uses. Compare the images before you remove playwright from the service's dependencies.

Frequently asked questions

What is a good Playwright alternative for screenshots?
For screenshots inside a product (thumbnails, social cards, PDFs, archives), a screenshot API such as RenderScreenshot replaces the browser with one HTTP request. For browser automation, Puppeteer is the closest library, and hosted browsers such as Browserless run Playwright for you.
Should I replace Playwright in my test suite?
No. Playwright Test's visual comparisons, traces and fixtures are built for CI. Keep Playwright for tests and use an API only for screenshots your production code makes.
How fast is a Playwright screenshot?
On an Apple M1 Pro with Playwright 1.64.1, a capture of example.com took 499 ms at p50 when launching Chromium each time, and 262 ms at p50 with a reused browser and a new context per capture (10 runs each, October 2026).
Does the API support Playwright's deviceScaleFactor and colorScheme?
Yes. deviceScaleFactor maps to viewport.scale (1 to 3) and colorScheme 'dark' maps to browser.dark_mode. Other context options such as locale, timezoneId, geolocation and httpCredentials map to browser and network parameters.

Related in this topic

Try RenderScreenshot free

150 free credits every month, no credit card required.