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 reachlocalhost. 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.
Related
- Taking screenshots with Playwright: the self-hosted guide.
- Puppeteer vs Playwright for screenshots: if you are choosing a library.
- Puppeteer alternative: the same question for Puppeteer, with a cost calculator.
- Viewport parameters: width, height, scale, device and mobile.