Automated screenshots: Playwright vs a screenshot API
Taking one screenshot is easy. Taking ten thousand, on a schedule, without a browser eating your server, is where it gets interesting.
Automated screenshots are program-generated captures of a web page, made without a person clicking anything. You either drive a headless browser like Playwright or Puppeteer yourself, or you POST a URL to a hosted screenshot API that runs the browser for you. Below roughly tens of thousands of captures a month, the API is usually cheaper once you count the engineering time; above that, self-hosting can pay off if someone owns the upkeep.
I build propzapi, a hosted screenshot and image API, so I have a side in this. I'll still give you the real code for doing it yourself, and I'll be honest about when running your own browser is the right call.
How do you take an automated screenshot with Playwright or Puppeteer?
You launch a headless browser, navigate to the URL, wait for the page to settle, and call the screenshot method. In Playwright that's page.screenshot(); Puppeteer uses the same name. Pass fullPage: true for the whole scrollable page, or a clip rectangle for a fixed region. Both default to a viewport-only PNG.
Here's the core of it in Playwright.
import { chromium } from "playwright";
const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto("https://example.com", { waitUntil: "networkidle" });
// viewport shot
await page.screenshot({ path: "shot.png" });
// full scrollable page (fullPage overrides the viewport height)
await page.screenshot({ path: "full.png", fullPage: true });
// a fixed region — note: clip and fullPage are mutually exclusive
await page.screenshot({ path: "crop.png", clip: { x: 0, y: 0, width: 1200, height: 630 } });
await browser.close(); Two defaults trip people up, and they're in the official Playwright docs. fullPage is false, so you get the viewport unless you ask for more. And clip and fullPage are mutually exclusive, so passing both throws an error.
Puppeteer's options match closely. Per the ScreenshotOptions reference, it also defaults to fullPage: false, type: 'png', and omitBackground: false. Set omitBackground: true for a transparent PNG, but know that it does nothing for JPEG, which renders the transparent area black.
So far, so simple. The hard part isn't the screenshot call. It's everything that makes the page look wrong when you're not watching.
Why do my full-page screenshots come out blank?
The usual culprit is lazy-loaded content. Images and sections that load when they scroll into view never load during a naive full-page capture, so they come out blank. There's a real behavioral split here: Playwright's fullPage scrolls the page internally while measuring its height, which trips many lazy loads; Puppeteer captures more as-is and leaves off-screen content empty. The fix in both is to scroll the page yourself, wait, then shoot.
This is the single most common way an automated screenshot silently breaks, and it shows up all over the trackers. Puppeteer's issue #5318 ("fullPage is not working correctly for this site") and #2423 ("blank screenshots for elements outside of viewport") are both this.
The fix is to walk the page before you capture.
// lazy-load / scroll-reveal fix: walk the page so content loads, then capture
await page.evaluate(async () => {
await new Promise((resolve) => {
let y = 0;
const step = Math.floor(window.innerHeight * 0.8);
const timer = setInterval(() => {
window.scrollTo(0, y);
y += step;
if (y >= document.body.scrollHeight) { clearInterval(timer); resolve(); }
}, 100);
});
});
await page.waitForLoadState("networkidle");
await page.evaluate(() => window.scrollTo(0, 0));
await page.screenshot({ path: "full.png", fullPage: true }); Three more failure modes are worth knowing before you ship this, because each one hands back a wrong image instead of an error.
Fonts are the first. A capture can fire before a web font loads, so the text renders in a fallback face. Playwright actually blocks on font network requests by default, which is why a screenshot can hang or fail outright on a custom-font site. Await document.fonts.ready, or if you need speed over fidelity, skip the wait with the PW_TEST_SCREENSHOT_NO_FONTS_READY flag, which some teams report cutting screenshot time more than tenfold.
Then there are single-page apps. A React or Vue app can fire load before it paints its first frame, so you capture a white div. Wait for a real element to appear, not the load event alone.
Last are timeouts. Playwright's "Timeout 30000ms exceeded" on screenshot is a common report, usually the font or network wait that never resolves on a heavy page.
How do you run headless Chromium on AWS Lambda without hitting the 250MB limit?
AWS Lambda caps an unzipped deployment at 250MB, and the full puppeteer package bundles a Chromium binary around 170MB that pushes you over. The fix is puppeteer-core plus a serverless Chromium build like @sparticuz/chromium, whose binary is 130.62 MiB uncompressed but ships Brotli-compressed near 50MB and unpacks to /tmp at cold start.
This is the tax nobody warns you about until your deploy fails. The numbers come from the @sparticuz/chromium project and the size discussion behind it.
Then there's memory. A headless Chrome instance with one active page runs roughly 300 to 500MB of RAM, and it climbs with page complexity. Run a few in parallel for a traffic spike and you feel it.
And cold starts. A plain Lambda cold-starts in the hundreds of milliseconds, but a Chromium one takes several seconds, because it has to unpack and launch a browser on top of the runtime. AWS also began billing the Lambda init phase in 2025, so those cold starts now cost money as well as latency.
None of this is a dealbreaker. It's just work, and it's work that has nothing to do with your product.
How do you schedule recurring screenshots?
For a handful of URLs, a GitHub Actions workflow on a cron schedule is the cheapest way to automate screenshots: an on.schedule trigger runs your capture script on a timer and commits the image back to the repo. For many URLs or tight timing, a scheduled Lambda or a hosted screenshot API with its own scheduler scales without you keeping a browser warm.
The keyword is automated screenshots, and scheduling is the part most tutorials skip. Here's the GitHub Actions version, which is genuinely enough for monitoring a few pages. The pattern is well documented.
# .github/workflows/screenshot.yml — a daily automated screenshot
name: daily-screenshot
on:
schedule:
- cron: "30 5 * * *" # 05:30 UTC every day
workflow_dispatch: # plus a manual button
jobs:
shot:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: npx playwright install --with-deps chromium
- run: node capture.js
- run: |
git add shots/
git commit -m "screenshot $(date -u +%F)" || echo "no change"
git push This is great for low volume and free on public repos. It has limits. GitHub's scheduled runs can be delayed under load, you're capped by Actions minutes, and every run pays the full cold start of installing Chromium.
A scheduled Lambda fixes the timing but keeps the whole browser-ops problem: the 250MB dance, the memory, the cold starts, plus a queue if you're capturing many URLs at once.
A hosted API moves the schedule and the browser off your plate entirely. You either hit its endpoint from your own cron, or use its built-in scheduler, and you never think about a Chromium version again.
Playwright or Puppeteer vs a screenshot API: which should you use?
Use Playwright or Puppeteer yourself when screenshots are core to your product, volume is high and steady, and someone owns the browser fleet. Use a hosted screenshot API when captures are a feature, not the business, and you'd rather not maintain Chromium. The rough break-even sits in the tens of thousands of captures a month; below it, the API usually wins once you price your own time.
Let me put the real trade on the table, because most articles won't.
| Factor | Self-host (Playwright / Puppeteer) | Serverless (Lambda + Chromium) | Hosted screenshot API |
|---|---|---|---|
| Setup | Browser pool, workers | 250MB dance, cold starts | One HTTP call |
| Render fidelity | Highest (full Chromium) | Highest | Highest (vendor Chromium) |
| Scaling | You own it | Auto, but cold + queued | Vendor's problem |
| Upkeep | Chromium upgrades, zombies | Same, plus init billing | None |
| Cost model | Servers + engineer time | Per-invocation + init | Per successful capture |
| Best at | High, steady volume | Spiky, moderate volume | Low to mid, or shipping fast |
On raw speed, hosted isn't magic. One 2026 benchmark from Microlink, itself a screenshot API, clocked cold-start latency across vendors from about 4,100ms to 9,500ms. A headless browser renders a real page in seconds no matter who runs it. Speed isn't why you reach for an API. You reach for it so the memory and the 2am pager stop being your problem.
The honest self-host case is real. Independent comparisons put the crossover near 30,000 to 50,000 captures a month, against hosted pricing around $4 to $10 per 1,000, per a 2026 Thunderbit review. Past that, running your own browsers gets cheaper per shot, as long as you already have the engineer to keep the fleet alive. If you don't, that "cheaper" number quietly excludes the most expensive line item.
How do you get past Cloudflare on automated screenshots?
Plain headless browsers get blocked because their fingerprint is obvious: navigator.webdriver is true, the plugins list is empty, and the automation flags give them away, so Cloudflare returns a challenge instead of the page. Getting through needs a hardened fingerprint and often a residential proxy. Common bot checks are beatable; a full Cloudflare Turnstile challenge usually is not.
This is the wall that ends a lot of screenshot projects. Cloudflare's bot management reads the automation tells and serves a JS challenge, so you capture the challenge page, not the site.
Stealth patches that spoof the user agent, hide the webdriver flag, and fix the plugins array clear the common checks. They do not clear a determined fingerprinting challenge. Be honest with yourself about which sites you actually need, and treat full Turnstile as out of scope rather than a bug to chase forever.
How does propzapi take automated screenshots?
propzapi renders a screenshot from one authenticated POST. You send a URL to /v1/screenshot, and get back a hosted image URL. Set full_page: true for the whole page, or stealth: true for a hardened fingerprint. It scrolls the page before a full-page shot so lazy content loads, waits for fonts, and only charges you when a capture actually succeeds.
Here's the whole thing.
curl -X POST https://api.propzapi.com/v1/screenshot \
-H "X-API-Key: pk_live_…" -H "Content-Type: application/json" \
-d '{"url":"https://example.com","full_page":true,"stealth":true}'
# → { "url": "https://images.propzapi.com/shot_9f2c….png",
# "source": "https://example.com", "width": 1280, "height": 800,
# "format": "png", "bytes": 240118 }
# X-Credits-Cost: 1 (a failed capture costs 0) The full-page mode handles the blank-capture problem from earlier for you: the engine walks the page so scroll-reveal sections and lazy images load, then captures. Stealth mode sends a real browser fingerprint for pages that block plain headless Chrome, with the same honest limit, it beats common checks, not a full Turnstile challenge.
Billing is on delivery. A capture that fails costs zero credits, and the exact cost comes back in an X-Credits-Cost header, so a retry loop can't quietly run up a bill. The screenshot API page has the full option list, and there's a 50-image free trial with no card. If you're wiring this into an assistant, it's also an MCP server, so an agent can capture a page as a tool call.
Frequently asked questions
- Why do my Puppeteer full-page screenshots come out blank?
- Usually lazy-loaded images. Puppeteer captures a full page largely as-is and doesn't scroll to trigger content that loads on scroll, so anything below the fold comes out empty. Playwright's fullPage scrolls internally while measuring the page height, which loads more of it. The reliable fix in either tool is to scroll top to bottom, wait for the network to go idle, scroll back, then capture.
- How do I run headless Chromium on AWS Lambda without hitting the 250MB limit?
- You drop the full puppeteer package, whose bundled Chromium is around 170MB, and use puppeteer-core plus a serverless build like @sparticuz/chromium. Its Chromium is 130.62 MiB uncompressed but ships Brotli-compressed near 50MB and unpacks to /tmp at cold start, which keeps the deployment under Lambda's 250MB unzipped limit.
- How do I schedule a screenshot of a website every day?
- For a few URLs, a GitHub Actions workflow on a cron schedule is the cheapest path: an on.schedule trigger like '30 5 * * *' runs daily, installs Playwright, captures the page, and commits the image. For many URLs or tight timing, a scheduled Lambda or a hosted screenshot API with its own scheduler scales better without you babysitting a browser.
- Should I self-host Puppeteer or pay for a screenshot API?
- Rough rule: below tens of thousands of captures a month, a hosted API is cheaper once you count engineering time. Self-hosting only pays off at high, steady volume and when someone owns the browser pool, cold starts, and Chromium upgrades. If screenshots aren't your core product, an API is almost always the better trade.
- How do I wait for fonts and content to load before a Playwright screenshot?
- Await document.fonts.ready and wait for the network to settle before you capture. Playwright already blocks on font requests by default, which is why a screenshot can hang on a custom-font site. If you need speed over fidelity you can skip the font wait with the PW_TEST_SCREENSHOT_NO_FONTS_READY env flag, but you risk capturing fallback fonts.
- How do I stop Cloudflare from blocking my automated screenshots?
- Plain headless browsers get flagged because navigator.webdriver is true, the plugins array is empty, and the automation fingerprint is obvious, so Cloudflare serves a challenge instead of the page. You need a hardened fingerprint, and often a residential proxy, to get past it. Basic bot checks are beatable; a full Cloudflare Turnstile challenge usually is not, so plan for the pages you can't capture.
Pick the tool that fits your volume
Automated screenshots come down to volume and ownership. If they're your product and the volume is high and steady, run Playwright or Puppeteer and own the fleet. If they're a feature and you'd rather ship, POST a URL to a hosted screenshot API and move on. Start free: grab a key, send one /v1/screenshot call, and watch the image come back before you decide.