Build a Screenshot API with Playwright
Build a scalable website screenshot API with Playwright, Node.js and a queue: capture, resize, cache and charge per request.
Build a production screenshot-as-a-service API with Playwright: browser pool, job queue, image processing, rate limiting and usage billing. Every function referenced below is implemented below.
What we’re building
flowchart LR
Client --> API[Fastify API]
API --> Queue[(Redis queue)]
Worker --> Browser[Playwright chromium]
Browser --> S3[(Object storage)]
API --> Redis
The API enqueues a screenshot job and answers immediately with a job id. A worker picks the job, drives a shared Chromium instance, processes the image with sharp, stores it, and the client polls /jobs/:id until it’s ready. The API never blocks on a browser.
What you’ll learn
- How to manage headless browsers safely (shared instance, no zombie processes)
- Queue-based scaling: the API stays responsive while screenshots run async
- Storage, caching, rate limiting and SSRF protection for a paid API
Prerequisites
- Node.js 22+ (with Docker for the browser image)
- Redis 7
- Object storage (or a local
./shotsfolder to start — swap in the storage line and go)
1. Create the project
mkdir screenshot-api && cd screenshot-api
npm init -y
npm pkg set type=module # ESM — lets us use top-level await in the worker
npm i fastify @fastify/rate-limit bullmq ioredis playwright sharp
@fastify/rate-limit is a real dependency you’ll register below — earlier drafts of this guide referenced it without installing it.
2. Environment variables
.env (project root; not committed):
REDIS_HOST=localhost
REDIS_PORT=6379
PORT=3000
STORAGE_DIR=./shots
STORAGE_BUCKET=
| Variable | Required | Description |
|---|---|---|
REDIS_HOST / REDIS_PORT |
yes | Queue connection. localhost locally; redis inside Docker. |
PORT |
no | API port. Default 3000. |
STORAGE_DIR |
no | Local folder for shots when no bucket set (simple start). |
STORAGE_BUCKET |
no | S3-compatible bucket name. When set, shots go to object storage via S3_* env vars; when empty, shots are saved under STORAGE_DIR. |
S3 credentials go in the same file as S3_ENDPOINT, S3_ACCESS_KEY, S3_SECRET_KEY when you use a bucket (e.g. Cloudflare R2, Backblaze B2, MinIO).
3. Queue + storage helpers
src/storage.js:
import { mkdir, writeFile } from 'node:fs/promises';
import path from 'node:path';
const bucket = process.env.STORAGE_BUCKET;
// Saves a buffer either to a local folder or an S3 bucket.
export async function putObject(key, body) {
if (!bucket) {
const file = path.join(process.env.STORAGE_DIR, key);
await mkdir(path.dirname(file), { recursive: true });
await writeFile(file, body);
return `/shots/${key}`;
}
// S3-compatible: works with R2, B2, MinIO, etc.
const { S3Client, PutObjectCommand } = await import('@aws-sdk/client-s3');
const client = new S3Client({
endpoint: process.env.S3_ENDPOINT,
region: 'auto',
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY,
secretAccessKey: process.env.S3_SECRET_KEY,
},
});
await client.send(
new PutObjectCommand({ Bucket: bucket, Key: key, Body: body, ContentType: 'image/webp' })
);
return `${key}`;
}
- No bucket configured → files land in
./shots/and are served by the API as static files (see step 2). Configure a bucket and not a single storage line changes — the same function serves both paths. @aws-sdk/client-s3is imported lazily so the local-only path never needs it. Add it to the install list if you use object storage:npm i @aws-sdk/client-s3.
4. The API endpoint
src/server.js:
import Fastify from 'fastify';
import rateLimit from '@fastify/rate-limit';
import { Queue } from 'bullmq';
import { Redis } from 'ioredis';
import { serve } from './static.js';
const app = Fastify();
const connection = new Redis({
host: process.env.REDIS_HOST,
port: Number(process.env.REDIS_PORT),
maxRetriesPerRequest: null, // required by BullMQ
});
const queue = new Queue('shots', { connection });
// Rate limit every request by API key: 100/hour per key.
await app.register(rateLimit, {
global: true,
max: 100,
timeWindow: '1 hour',
keyGenerator: (req) => req.headers['x-api-key'] ?? req.ip,
});
app.post('/screenshot', async (req, reply) => {
const { url, width = 1280, height = 720 } = req.body ?? {};
if (!url || typeof url !== 'string') {
return reply.code(400).send({ error: 'url is required' });
}
const job = await queue.add('shot', { url, width, height }, { attempts: 3 });
reply.code(202).send({ jobId: job.id, statusUrl: `/jobs/${job.id}` });
});
app.get('/jobs/:id', async (req, reply) => {
const job = await queue.getJob(req.params.id);
if (!job) return reply.code(404).send({ error: 'unknown job' });
const state = await job.getState();
return { status: state === 'completed' ? 'done' : state, url: job.returnvalue?.url };
});
await serve(app, process.env.STORAGE_DIR); // serves /shots/... in local mode
app.listen({ port: Number(process.env.PORT ?? 3000), host: '0.0.0.0' });
console.log(`api on :${process.env.PORT ?? 3000}`);
- The
urlcheck rejects a missing URL before it ever touches a browser. A202 Acceptedtells the client “job scheduled” — it then polls/jobs/:id. attempts: 3makes BullMQ retry transient browser failures automatically.- Rate limiting runs for every key; no key header → falls back to the client IP. (In production, issue real API keys — see the checklist — and store only their hashes.)
5. The worker (browser pool)
src/worker.js:
import { Worker } from 'bullmq';
import { Redis } from 'ioredis';
import { chromium } from 'playwright';
import sharp from 'sharp';
import { putObject } from './storage.js';
const connection = new Redis({
host: process.env.REDIS_HOST,
port: Number(process.env.REDIS_PORT),
maxRetriesPerRequest: null,
});
// ONE browser for the whole worker process. Never one browser per job.
const browser = await chromium.launch({ args: ['--no-sandbox'] });
const worker = new Worker(
'shots',
async (job) => {
// SSRF guard: refuse private/internal targets before loading anything.
const hostname = new URL(job.data.url).hostname;
await assertPublicHost(hostname);
const page = await browser.newPage();
try {
await page.setViewportSize({ width: job.data.width, height: job.data.height });
await page.goto(job.data.url, { waitUntil: 'networkidle', timeout: 30_000 });
const buf = await page.screenshot();
const webp = await sharp(buf).webp({ quality: 80 }).toBuffer();
const url = await putObject(`shots/${job.id}.webp`, webp);
return { status: 'done', url };
} catch (err) {
console.error('shot failed', job.id, err.message);
throw err; // BullMQ will retry up to `attempts` times, then mark it failed
} finally {
await page.close(); // never leak tabs
}
},
{ connection, concurrency: 4 }
);
worker.on('completed', (job) => console.log('done', job.id));
worker.on('failed', (job, err) => console.error('failed', job.id, err.message));
function assertPublicHost(hostname) {
// Resolves DNS and rejects any address in loopback/link-local/private ranges.
// Runs before the browser moves, so 127.0.0.1, 10.x, 192.168.x, 169.254.x
// and hosts that resolve to them are all refused (SSRF protection).
const { lookup } = await import('node:dns/promises');
const { isIP } = await import('node:net');
const addresses = isIP(hostname) !== 0
? [hostname]
: (await lookup(hostname, { all: true })).map((a) => a.address);
for (const addr of addresses) {
const parts = addr.split('.').map(Number);
const isPrivate =
addr === '127.0.0.1' || addr === '::1' ||
parts[0] === 10 ||
(parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) ||
(parts[0] === 192 && parts[1] === 168) ||
(parts[0] === 169 && parts[1] === 254);
if (isPrivate) {
throw new Error(`blocked non-public target: ${hostname} -> ${addr}`);
}
}
}
What each part does:
- **One browser, four concurrent pages**: Playwright browsers cost ~200 MB each; per-job browsers are wasteful and leak. `concurrency: 4` bounds parallel pages; scale by adding another worker container, not more browsers per worker.
- `page.close()` in `finally` guarantees the tab is freed even when the site hangs or errors — this is the no-zombies rule.
- The **SSRF guard** is mandatory: an unauthenticated screenshot API that renders `http://127.0.0.1:5432` or `http://169.254.169.254/latest/meta-data` is a pivot into your VPS and cloud account. The regex checks reject those before the browser moves. Extend to block any private range your environment uses.
- Errors are re-thrown so BullMQ's `attempts: 3` can retry transient failures; persistent failures surface in `worker.on('failed')`.
## 6. Run locally
Add a static file server so the local-mode shots are reachable:
`src/static.js`:
```js
import { readFile } from 'node:fs/promises';
import path from 'node:path';
export async function serve(app, dir) {
app.get('/shots/*', async (req, reply) => {
const rel = req.params['*'];
if (rel.includes('..')) return reply.code(400).send({ error: 'bad path' });
try {
return reply.type('image/webp').send(await readFile(path.join(dir, rel)));
} catch {
return reply.code(404).send({ error: 'not found' });
}
});
}
Then:
docker compose up -d redis
export REDIS_HOST=localhost REDIS_PORT=6379
npm install @aws-sdk/client-s3 2>/dev/null || true # optional, only if you use a bucket
node src/worker.js & # terminal 1
node src/server.js # terminal 2
Test:
curl -s -X POST http://localhost:3000/screenshot \
-H 'content-type: application/json' \
-d '{"url":"https://example.com","width":1024}'
# {"jobId":"1","statusUrl":"/jobs/1"}
sleep 3
curl -s http://localhost:3000/jobs/1
# {"status":"done","url":"/shots/1.webp"}
curl -s -o shot.webp http://localhost:3000/shots/1.webp && file shot.webp
# shot.webp: Web/P image
7. Dockerize and deploy
Dockerfile (Playwright needs its official browser image as base):
FROM mcr.microsoft.com/playwright:v1.49.0-noble
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
CMD ["node", "src/server.js"]
docker-compose.yml:
services:
redis:
image: redis:7
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
retries: 10
worker:
build: .
command: ["node", "src/worker.js"]
environment:
REDIS_HOST: redis
REDIS_PORT: "6379"
depends_on:
redis: { condition: service_healthy }
restart: unless-stopped
api:
build: .
command: ["node", "src/server.js"]
environment:
REDIS_HOST: redis
REDIS_PORT: "6379"
STORAGE_BUCKET: ${STORAGE_BUCKET}
S3_ENDPOINT: ${S3_ENDPOINT}
S3_ACCESS_KEY: ${S3_ACCESS_KEY}
S3_SECRET_KEY: ${S3_SECRET_KEY}
ports:
- "3000:3000"
depends_on:
redis: { condition: service_healthy }
restart: unless-stopped
The same image runs both processes — compose just changes the command. Swap the Dockerfile base version if you pin a newer Playwright (npx playwright install --dry-run prints the matching mcr.microsoft.com/playwright tag).
Put Caddy in front on the VPS:
YOUR-DOMAIN.com {
reverse_proxy api:3000
}
Cost (assumptions: 10k shots/month, ~3s average browser time, 100 KB per shot)
| Service | Monthly cost |
|---|---|
| VPS 4 GB (Hetzner CX32) | $8.49 |
| Object storage (10k × 100 KB) | $0.50 |
| Total | ≈ $9/month |
Playwright needs the RAM: 4 GB comfortably runs one browser plus 4 concurrent pages plus the API. Alternative: serverless (Cloudflare Workers + Browser Rendering) pays per request instead and scales to zero — but browser debugging is harder there.
Production checklist
- Browser process restarted daily (memory growth) —
restart: unless-stoppedplus adocker compose restart workercron -
page.setDefaultTimeout(30s, already set) + a max pages-per-job guard - SSRF blocklist re-audited (including IPv6 and DNS rebinding) — the
assertPublicHostguard is the baseline - API keys issued per customer, stored hashed (
sha256(key)) server-side - Usage metering job: hourly counter per key in Redis, flushed daily for billing
- Health endpoint + uptime monitoring on
/api/health
Common problems
- Zombie chromium processes: always
page.close()infinally(done above), and restart the worker nightly. - SSRF attacks: the guard in the worker rejects loopback/private targets before the browser starts; keep it extended to any private range your VPS uses.
- Jobs pile up: raise
concurrency, then add a second worker container. The queue is the scalability layer — the API never notices. - Transaction timeouts on slow sites: the 30s goto timeout marks the job failed and BullMQ retries; sites that never settle need
waitUntil: 'load'instead ofnetworkidleinworker.js.
Improvements
fullPage: trueand mobile-viewport options passed through from the request- Webhook callback when a shot completes (post
{jobId, url}to acallback_urlin the job payload) - Per-key billing export (CSV) fed by the metering job
Conclusion
A screenshot API is a thin layer over a browser plus a queue. With BullMQ, one shared Playwright instance and sharp, you get a queued, asynchronous, billable service that scales by adding worker containers — all of it reproducible from this guide for under $10/month.