# Puppeteer and Playwright screenshots, without running a browser

> Your Puppeteer or Playwright screenshot script as one HTTP request: the same capture without running Chromium. Code side by side, and when to keep your own.

A Puppeteer or Playwright screenshot script launches Chromium, opens the page, waits, captures and closes the browser. CurlShot does those steps on its own servers: you send one HTTP request with the page address and the same settings as options (viewport, full page, wait condition, PDF paper size) and get the file back. Your code keeps the result and drops the browser, the Docker image, the fonts and the memory limits that come with it.

Page for people: https://curlshot.com/tools/puppeteer-screenshot-api

![A full-page capture of the Wikipedia article about the Eiffel Tower, from one request](https://curlshot.com/docs/examples/wikipedia-full-page.webp "full_page=true in one request: what fullPage: true gives in Puppeteer, without a browser on your side.")

## How to replace a Puppeteer screenshot script with an API call in three steps

1. Find the page.screenshot or page.pdf call in your script and note its settings.
2. Write the same settings as options of one request: viewport_width, full_page, wait_until and so on.
3. Replace the browser launch and the capture with that request, and delete the browser from your build.

## The same capture in Puppeteer, Playwright and one request

Each sample below takes a full-page screenshot of a page at 1280 × 1024 after the network has gone quiet. The first two launch a browser on your machine; the last two send the work to the API. The cookie banner is the giveaway: in a script you hide it by hand, site by site, while the API has a switch for the common consent tools.

- viewport_width=1280 and viewport_height=1024 are the defaults, so the shorter Python request gives the same image.
- wait_until also accepts networkidle0 and networkidle2, the Puppeteer names, and treats both as networkidle.

Puppeteer:

```javascript
// npm install puppeteer  (downloads a browser of its own)
import puppeteer from 'puppeteer'

const browser = await puppeteer.launch()
try {
  const page = await browser.newPage()
  await page.setViewport({ width: 1280, height: 1024 })
  await page.goto('https://example.com', { waitUntil: 'networkidle0', timeout: 30000 })
  // The cookie banner of this one site, hidden by hand.
  await page.addStyleTag({ content: '#cookie-banner { display: none !important; }' })
  await page.screenshot({ path: 'example.png', fullPage: true })
} finally {
  await browser.close()
}
```

Playwright (Node.js):

```javascript
// npm install playwright && npx playwright install chromium
import { chromium } from 'playwright'

const browser = await chromium.launch()
try {
  const page = await browser.newPage({ viewport: { width: 1280, height: 1024 } })
  await page.goto('https://example.com', { waitUntil: 'networkidle', timeout: 30000 })
  await page.addStyleTag({ content: '#cookie-banner { display: none !important; }' })
  await page.screenshot({ path: 'example.png', fullPage: true })
} finally {
  await browser.close()
}
```

The API (Node.js, no browser):

```javascript
// Node.js 18 or newer, nothing to install.
import { writeFile } from 'node:fs/promises'

const params = new URLSearchParams({
  url: 'https://example.com',
  viewport_width: '1280',
  viewport_height: '1024',
  wait_until: 'networkidle',
  timeout: '30',
  block_cookie_banners: 'true',
  full_page: 'true',
})
const response = await fetch('https://curlshot.com/api/v1/screenshot?' + params, {
  headers: { 'X-Access-Key': process.env.SCREENSHOT_ACCESS_KEY },
})
if (!response.ok) throw new Error((await response.json()).error_message)
await writeFile('example.png', Buffer.from(await response.arrayBuffer()))
```

The API (Python, no browser):

```python
# Replaces: sync_playwright() -> chromium.launch() -> page.goto() -> page.screenshot()
import os

import requests

response = requests.get(
    "https://curlshot.com/api/v1/screenshot",
    headers={"X-Access-Key": os.environ["SCREENSHOT_ACCESS_KEY"]},
    params={"url": "https://example.com", "wait_until": "networkidle", "block_cookie_banners": "true", "full_page": "true"},
    timeout=120,
)
response.raise_for_status()
with open("example.png", "wb") as f:
    f.write(response.content)
```

## Puppeteer and Playwright calls, and the option that replaces each

Most of a screenshot script is settings. Each line of the table is one call you can delete, and the option that does the same in the request. Times are in seconds in the API, not milliseconds.

| Puppeteer | Playwright | API option |
| --- | --- | --- |
| page.setViewport({ width, height }) | newPage({ viewport }) | viewport_width, viewport_height |
| setViewport({ deviceScaleFactor: 2 }) | newPage({ deviceScaleFactor: 2 }) | device_scale_factor=2 |
| page.emulate(KnownDevices[...]) | newPage({ ...devices[...] }) | viewport_device, e.g. iphone_15_pro |
| goto(url, { waitUntil: 'networkidle0' }) | goto(url, { waitUntil: 'networkidle' }) | wait_until=networkidle |
| goto(url, { timeout: 30000 }) | goto(url, { timeout: 30000 }) | timeout=30 |
| page.waitForSelector(sel) | page.locator(sel).waitFor() | wait_for_selector |
| a sleep of 2000 ms | page.waitForTimeout(2000) | delay=2 |
| screenshot({ fullPage: true }) | screenshot({ fullPage: true }) | full_page=true |
| screenshot({ type: 'jpeg', quality: 80 }) | screenshot({ type: 'jpeg', quality: 80 }) | format=jpeg, image_quality=80 |
| screenshot({ omitBackground: true }) | screenshot({ omitBackground: true }) | omit_background=true |
| screenshot({ clip }) | screenshot({ clip }) | clip_x, clip_y, clip_width, clip_height |
| (await page.$(sel)).screenshot() | page.locator(sel).screenshot() | selector |
| page.pdf({ format: 'A4', landscape, margin }) | page.pdf({ format: 'A4', landscape, margin }) | format=pdf, pdf_paper_format, pdf_landscape, pdf_margin |
| pdf({ printBackground: true }) | pdf({ printBackground: true }) | pdf_print_background (on by default) |
| page.emulateMediaType('print') | page.emulateMedia({ media: 'print' }) | media_type=print |
| emulateMediaFeatures([{ name: 'prefers-color-scheme', value: 'dark' }]) | page.emulateMedia({ colorScheme: 'dark' }) | dark_mode=true |
| page.setContent(html) | page.setContent(html) | html, in a POST body |
| page.setCookie(...) | context.addCookies([...]) | cookies |
| page.setExtraHTTPHeaders({...}) | newPage({ extraHTTPHeaders }) | headers |
| page.setUserAgent(ua) | newPage({ userAgent }) | user_agent |
| page.emulateTimezone('Europe/Paris') | newPage({ timezoneId: 'Europe/Paris' }) | time_zone=Europe/Paris |
| page.addStyleTag({ content }) | page.addStyleTag({ content }) | styles, or hide_selectors to hide elements |
| page.evaluate(fn) | page.evaluate(fn) | scripts |
| page.click(sel) | page.click(sel) | click |
| setRequestInterception + request.abort() | page.route(pattern, (r) => r.abort()) | block_requests, block_resources |

## What you stop running when the browser goes

The script above is a dozen lines. What surrounds it in production is not, and none of it is visible until something breaks. This is the work that leaves your servers with the browser.

| With your own Chromium | With the API |
| --- | --- |
| A build or Docker image with Chromium and its system libraries | An HTTP client |
| A memory budget per browser, and restarts when one hangs or crashes | Not your process |
| A queue and a cap on parallel pages, sized for your busiest hour | Your plan's rate and concurrency limits, with Retry-After |
| Rules to hide cookie banners and ads, kept current by someone | block_cookie_banners, block_ads, block_trackers, block_chats |
| Checks that stop user-supplied URLs from reaching your own network | Private addresses refused, redirects included |
| Chromium updates, and a check that pages still render the same after each | Not part of your build or your releases |
| Somewhere to keep the files and links that expire | response_type=json returns a stored file link |

- The costs are real on both sides: the API is a monthly bill, your own browser is servers and the time of the person who keeps it running. The blog article below works through the numbers.

## When your own Puppeteer or Playwright is still the right call

An API is not a replacement for a browser automation library. Keep your own when one of these is true.

- The flow is a session: log in by typing, pass a second factor, fill a form, move across pages, then capture.
- The page lives on localhost, an intranet or a private network. The API refuses those addresses; for one-off pages you can send the HTML instead.
- You run end-to-end or visual tests in CI. Playwright Test and its toHaveScreenshot assertion belong next to your code.
- You need Firefox or WebKit, not only Chromium.
- Your volume is large and steady enough that one dedicated server, kept busy all month, costs less than a plan.
- Pages may not leave your infrastructure for legal or contractual reasons.

## What people use it for

- A screenshot step in a web app that should not ship Chromium
- Serverless functions that are too small to run a browser
- Teams that are tired of keeping a headless browser fleet alive
- Scripts that only ever open one page and capture it

## The same thing with the API

```bash
curl "https://curlshot.com/api/v1/screenshot?access_key=YOUR_ACCESS_KEY&url=https://example.com&full_page=true&wait_until=networkidle&block_cookie_banners=true" \
  --output example.png
```

An access key is free: https://curlshot.com/register. Read more:

- [Getting started](https://curlshot.com/docs/getting-started.md)
- [Waiting and timing](https://curlshot.com/docs/waiting.md)
- [Options reference](https://curlshot.com/docs/options.md)

## Questions

### Is there a hosted Puppeteer screenshot API?

CurlShot is one: you send the settings you would pass to page.screenshot or page.pdf as options of an HTTP request, and it runs the browser for you. It is not a remote Puppeteer endpoint you connect to; there is no session to drive, only the capture.

### Which browser does it use?

Chromium, the open-source engine behind Chrome: the same engine Puppeteer and Playwright drive by default. Firefox and WebKit are not offered, so keep Playwright for tests that must run in those.

### Can I keep my script and only move the screenshot step?

Yes, if the page can be reached from the internet. The API opens the page itself, so it does not share your browser session: pass the cookies or headers the page needs as options.

### Are timeouts and delays in milliseconds, as in Puppeteer?

No. timeout and delay are in seconds: timeout=30 is Puppeteer's timeout: 30000. timeout goes up to 90 seconds and delay up to 30.

### Can it type into forms or follow several pages?

Only a little: one click on a selector, your own JavaScript before the capture, and cookies or headers for a session. A flow that types, submits and moves across pages is a job for your own Puppeteer or Playwright.

## Related

- [Screenshot API](https://curlshot.com/tools/screenshot-api.md): A screenshot API for developers. One HTTP request turns a URL, HTML or Markdown into a PNG, JPEG, WebP or PDF. Full page, devices, blocking, cache, MCP.
- [Element screenshot](https://curlshot.com/tools/element-screenshot.md): Capture one part of a web page instead of all of it: a chart, a pricing table, a card. Name the element with a CSS selector and get an image of it alone.
- [Free screenshot API](https://curlshot.com/tools/free-screenshot-api.md): A free screenshot API plan with a monthly allowance and no card: the same endpoint and options as the paid plans. What it includes, its limits, when to upgrade.

## Guides

- [Screenshot API vs headless browser: the real cost](https://curlshot.com/blog/screenshot-api-vs-headless-browser.md): A cost calculator with every assumption editable, the hidden work behind headless Chrome, and an honest checklist for build or buy.
- [Website screenshot code examples in 8 languages](https://curlshot.com/blog/screenshot-code-examples.md): The shortest working code to save a full-page screenshot of a website in curl, Node.js, Python, PHP, Go, Ruby, Java and C#, side by side.
