Website screenshot API: Playwright or one request (2026)
Taking one screenshot of a web page with a headless browser is a ten-line script. Taking thousands, of pages you don't control, from URLs your users type in, is a different job: cookie banners, lazy-loaded images, pages that never finish loading, and the security problem of letting strangers point your server's browser anywhere. This guide starts with Playwright, covers the production pitfalls, shows PDFOven's /v1/screenshot, and ends with generating Open Graph images.
Screenshots with Playwright
npm install playwright
npx playwright install chromium
import { chromium } from "playwright";
const browser = await chromium.launch();
const context = await browser.newContext({
viewport: { width: 1440, height: 900 },
deviceScaleFactor: 2,
});
const page = await context.newPage();
await page.goto("https://example.com", { waitUntil: "load" });
await page.screenshot({ path: "viewport.png" });
await page.screenshot({ path: "full.png", fullPage: true });
await browser.close();
deviceScaleFactor: 2 gives retina-sharp images at twice the pixel dimensions. fullPage: true captures the whole scrollable page instead of just the viewport. For JPEG, pass type: "jpeg" and a quality.
Dark mode
Many sites follow the operating system's theme through prefers-color-scheme. Set it on the context:
const context = await browser.newContext({ colorScheme: "dark" });
Hiding cookie banners and chat widgets
Inject CSS that hides them before you capture:
await page.addStyleTag({
content: `
#onetrust-banner-sdk, .cookie-banner, [aria-label="cookie consent"],
#intercom-container, .crisp-client { display: none !important; }
`,
});
Recent Playwright versions also accept a style option on screenshot() that applies CSS only while the screenshot is taken. Hiding is usually better than clicking "Accept": you don't accept terms on the user's behalf, and you don't need site-specific button selectors.
One element
To capture a single component, such as a pricing table or a chart, screenshot a locator:
await page.locator("#pricing").screenshot({ path: "pricing.png" });
Playwright scrolls the element into view and crops to its bounding box.
Production pitfalls
SSRF. If users supply the URL, they can point your browser at http://169.254.169.254/ (cloud metadata with credentials), http://localhost:6379, or your internal admin panel, and you'll helpfully send them a picture of it. Checking the URL string isn't enough: a public hostname can resolve to a private IP, redirect to one, or change its DNS answer between your check and the browser's request. Block private, loopback and link-local ranges for every request the page makes, including redirects and subresources, by routing them through a check (Playwright's page.route or context.route), or run the browser in a network that simply can't reach anything internal.
Lazy-loaded images. Images with loading="lazy" or an IntersectionObserver only load near the viewport. A full-page screenshot captures the page without scrolling through it, so images far down the page can be blank. Scroll first:
await page.evaluate(async () => {
for (let y = 0; y < document.body.scrollHeight; y += window.innerHeight) {
window.scrollTo(0, y);
await new Promise((resolve) => setTimeout(resolve, 150));
}
window.scrollTo(0, 0);
});
Fonts. Web fonts may still be loading when load fires, and a minimal Linux container has no fonts for CJK, emoji or many other scripts, so text renders as empty boxes. Wait for document.fonts.ready, and install font packages such as Noto in your image.
Timeouts. waitUntil: "networkidle" waits for 500 ms with no network traffic, which pages with analytics beacons, polling or websockets may never reach. Prefer load plus an explicit wait for the element you care about, and always set a hard timeout so one slow site can't tie up a browser.
Resources. Every concurrent screenshot is a browser context using tens to hundreds of megabytes. Cap concurrency, reuse the browser process, and close contexts in finally blocks.
The PDFOven way: /v1/screenshot
POST /v1/screenshot is /v1/image with a url, plus options made for pages you don't control:
| Option | What it does |
|---|---|
options. / options. |
Viewport size in pixels. Default 1280 by 800 |
options. |
Capture the whole scrollable page |
options. |
"light" (default) or "dark" |
options. |
CSS selectors to hide first, such as cookie banners and chat widgets |
options. |
Capture just the first element matching this selector |
options. |
Wait 0 to 10,000 ms after loading, for animations |
options. |
1 to 3; 2 for retina-sharp images |
It returns a PNG, or a JPEG with ?type=jpeg.
curl "https://pdfoven.com/v1/screenshot?type=jpeg" \
-H "Authorization: Bearer $PDFOVEN_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com",
"options": {
"width": 1440,
"full_page": true,
"color_scheme": "dark",
"hide_selectors": ["#onetrust-banner-sdk", "#intercom-container"]
}
}' \
--output page.jpg
Capturing one element from Python:
import os
import requests
resp = requests.post(
"https://pdfoven.com/v1/screenshot",
headers={"Authorization": f"Bearer {os.environ['PDFOVEN_KEY']}"},
json={
"url": "https://example.com/pricing",
"options": {"selector": "#pricing", "device_scale_factor": 2, "delay_ms": 500},
},
timeout=90,
)
if not resp.ok:
err = resp.json()["error"]
raise RuntimeError(f"{err['code']}: {err['message']}")
with open("pricing.png", "wb") as f:
f.write(resp.content)
PDFOven only fetches public addresses, for the page and everything it loads, so you can pass user-supplied URLs without building SSRF protection yourself. It waits for the network to go quiet and for web fonts before capturing, up to timeout_ms (30 seconds by default, 60 at most), and wait_for_selector waits for a specific element. It doesn't scroll the page first, so on sites that lazy-load images far down the page, a full_page capture can still show placeholders there. A page that returns an HTTP error, or a selector that matches nothing visible, gives a render_failed error that doesn't count against your quota.
AI agents can take screenshots with the render_image MCP tool, and the free website screenshot tool works without signing up.
Bonus: Open Graph images from a template
The image link previews show on social sites and in chat apps comes from the og:image meta tag. The usual size is 1200 by 630 pixels. Rather than screenshotting a page, you can render one of PDFOven's built-in 1200 by 630 social cards with /v1/image and send only the text:
tpl_og_article: blog posts and pages. Fields:site,eyebrow,title,subtitle,author,date,accent,dark.tpl_og_product: products and listings. Fields:brand,name,tagline,price,badge,accent.tpl_og_announcement: launches and events on a gradient. Fields:label,headline,detail,site,cta,color_from,color_to.
Long titles shrink to fit. Fields you leave out come from the template's sample data, so send every field you want to control, including an empty string for ones you want blank.
import { writeFile } from "node:fs/promises";
const res = await fetch("https://pdfoven.com/v1/image", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.PDFOVEN_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
template_id: "tpl_og_article",
data: {
site: "acme.dev",
eyebrow: "Engineering",
title: "How we cut our build times in half",
subtitle: "Caching, parallelism and one very slow test.",
author: "Ada Lovelace",
date: "2026-10-21",
dark: true,
},
}),
});
if (!res.ok) throw new Error((await res.json()).error.message);
await writeFile("public/og/build-times.png", Buffer.from(await res.arrayBuffer()));
Then point the page at the file:
<meta property="og:image" content="https://acme.dev/og/build-times.png" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta name="twitter:card" content="summary_large_image" />
Generate the image once when a post is published and serve the stored file, rather than rendering on every crawler request; each render counts toward your quota. To try the templates and copy the request, use the free social image generator.
When to use which
- Playwright when you screenshot your own pages, need scripted interaction before the capture (logging in, clicking through), or already run browsers for testing.
- PDFOven when you capture arbitrary public URLs and want dark mode, banner hiding, element capture and SSRF protection without operating a browser fleet, or want social cards without designing them. Every account gets 100 free renders a month; paid plans are $9, $39 and $99 for 2,000, 15,000 and 60,000.
Skip the browser plumbing
PDFOven runs Chromium for you. Send HTML, Markdown, a URL or JSON to a template and get a PDF back. 100 free renders every month.
Get a free API key