Puppeteer was supposed to make browser automation easy. And for local dev, it often is. But the moment you push it to CI/CD, things get messy fast.
Here's what a typical Puppeteer setup looks like in a GitHub Actions workflow:
- name: Install Chrome dependencies
run: |
sudo apt-get install -y libnss3 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 libxfixes3 libxrandr2 libgbm1 libasound2
- name: Run screenshot tests
run: node screenshot-tests.js
env:
PUPPETEER_SKIP_CHROMIUM_DOWNLOAD: false
And that's before you hit the real problems:
- Chrome crashes with "Error: Protocol error (Target.createTarget): Target closed"
- Memory leaks on long test runs that OOM-kill your runner
- Flaky timeouts because CI machines are slower than your laptop
- Different Chrome versions across environments causing visual diffs
The Root Problem
Puppeteer in CI means you're running a full browser as a side effect of your build pipeline. That browser needs:
- A specific Chrome/Chromium version
- A bunch of native system libraries
- Enough RAM (Chrome is hungry)
- A display server or Xvfb
None of this belongs in a CI runner. It's fragile, slow to set up, and one upstream Chrome update away from breaking everything.
The Better Pattern: External Screenshot API
What if you just... didn't run a browser in CI at all?
An external screenshot API runs the browser for you, handles all the infrastructure, and gives you a simple HTTP endpoint. Your CI job just makes an API call.
Here's the same workflow using SnapAPI:
- name: Run screenshot tests
run: node screenshot-tests.js
env:
SNAPAPI_KEY: ${{ secrets.SNAPAPI_KEY }}
No Chrome dependencies. No Xvfb. No memory leaks. Just a network call.
Working Code Examples
Node.js: Capture a screenshot and save it
const fs = require('fs');
const https = require('https');
async function captureScreenshot(url, outputPath) {
const payload = JSON.stringify({
url: url,
format: 'png',
width: 1280,
height: 800,
wait_for: 'networkidle'
});
const options = {
hostname: 'api.opspawn.com',
path: '/api/screenshot',
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': process.env.SNAPAPI_KEY,
'Content-Length': Buffer.byteLength(payload)
}
};
return new Promise((resolve, reject) => {
const req = https.request(options, (res) => {
const chunks = [];
res.on('data', chunk => chunks.push(chunk));
res.on('end', () => {
if (res.statusCode === 200) {
const buf = Buffer.concat(chunks);
fs.writeFileSync(outputPath, buf);
resolve(outputPath);
} else {
reject(new Error(`API error: ${res.statusCode} ${Buffer.concat(chunks).toString()}`));
}
});
});
req.on('error', reject);
req.write(payload);
req.end();
});
}
// In your test suite:
async function runVisualTests() {
const pages = [
{ url: 'https://your-staging-app.com/', name: 'homepage' },
{ url: 'https://your-staging-app.com/dashboard', name: 'dashboard' },
{ url: 'https://your-staging-app.com/checkout', name: 'checkout' },
];
for (const page of pages) {
const path = `screenshots/${page.name}-${Date.now()}.png`;
await captureScreenshot(page.url, path);
console.log(`✓ Captured ${page.name} → ${path}`);
}
}
runVisualTests().catch(console.error);
Python: Screenshot-as-a-service in pytest
import os
import requests
import pytest
from pathlib import Path
SNAPAPI_KEY = os.environ["SNAPAPI_KEY"]
SCREENSHOTS_DIR = Path("test-screenshots")
SCREENSHOTS_DIR.mkdir(exist_ok=True)
def capture_screenshot(url: str, name: str, width: int = 1280) -> Path:
resp = requests.post(
"https://api.opspawn.com/api/screenshot",
headers={"X-API-Key": SNAPAPI_KEY},
json={
"url": url,
"format": "png",
"width": width,
"height": 800,
"wait_for": "networkidle",
},
timeout=30,
)
resp.raise_for_status()
out = SCREENSHOTS_DIR / f"{name}.png"
out.write_bytes(resp.content)
return out
@pytest.mark.parametrize("page,url", [
("homepage", "https://your-app.com/"),
("pricing", "https://your-app.com/pricing"),
("docs", "https://your-app.com/docs"),
])
def test_page_renders(page, url):
screenshot = capture_screenshot(url, page)
assert screenshot.exists()
assert screenshot.stat().st_size > 10_000, f"{page} screenshot too small (blank page?)"
print(f"✓ {page}: {screenshot.stat().st_size // 1024}KB")
Generating PDFs for reports
async function generatePDFReport(url, outputPath) {
const resp = await fetch('https://api.opspawn.com/api/screenshot', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': process.env.SNAPAPI_KEY
},
body: JSON.stringify({
url: url,
format: 'pdf',
wait_for: 'networkidle'
})
});
if (!resp.ok) throw new Error(`API error: ${resp.status}`);
const buf = await resp.arrayBuffer();
require('fs').writeFileSync(outputPath, Buffer.from(buf));
return outputPath;
}
Integrating into GitHub Actions
name: Visual Regression Tests
on:
pull_request:
branches: [main]
jobs:
screenshots:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: npm ci
- name: Run visual tests
env:
SNAPAPI_KEY: ${{ secrets.SNAPAPI_KEY }}
TEST_BASE_URL: https://staging.your-app.com
run: node tests/visual.js
- name: Upload screenshots
uses: actions/upload-artifact@v4
with:
name: screenshots
path: screenshots/
No apt-get install libatk.... No Chrome setup. The runner just needs Node.js (which it already has).
What You Get
Compared to self-hosted Puppeteer/Playwright in CI:
| Self-hosted browser | SnapAPI | |
|---|---|---|
| CI setup time | 2-5 min (dependencies) | 0 |
| Memory usage | 300-800MB | ~0 (HTTP call) |
| Flakiness | High (race conditions) | Low (managed infra) |
| Cross-env consistency | Varies by Chrome version | Consistent |
| Maintenance | Your problem | Not your problem |
When NOT to Use an API
External screenshot APIs are great for CI and scheduled jobs, but there are cases where local Puppeteer still makes sense:
- You need to intercept network requests or mock API responses
- You're testing browser-specific behavior (extensions, specific Chrome versions)
- You have strict data residency requirements and can't send URLs to an external service
- You need sub-100ms response times for real-time features
For everything else — visual regression tests, PDF generation, social card previews, monitoring screenshots — offloading to an API is the right call.
Try SnapAPI free → opspawn.com/snapapi
No credit card required to start. Plans from $19/month for 5,000 screenshots.
Top comments (0)