How to Build a Screenshot API with Node.js (2026)
Building your own screenshot API is a great learning exercise — and sometimes the right production choice. This guide builds a complete screenshot service from a minimal Express endpoint up to a production setup with browser pooling, BullMQ job queues, S3 caching, and rate limiting. At the end, we compare the build cost to using a managed API like SnapAPI so you can make an informed decision.
v1: Minimal Screenshot Endpoint
npm install express playwright
const express = require('express');
const { chromium } = require('playwright');
const app = express();
app.use(express.json());
app.get('/screenshot', async (req, res) => {
const { url, width = 1280, height = 800, fullPage = false } = req.query;
if (!url) return res.status(400).json({ error: 'url required' });
let browser;
try {
browser = await chromium.launch({ headless: true });
const page = await browser.newPage();
await page.setViewportSize({ width: +width, height: +height });
await page.goto(url, { waitUntil: 'networkidle', timeout: 30000 });
const screenshot = await page.screenshot({
fullPage: fullPage === 'true',
type: 'png'
});
res.setHeader('Content-Type', 'image/png');
res.send(screenshot);
} catch (err) {
res.status(500).json({ error: err.message });
} finally {
await browser?.close();
}
});
app.listen(3000, () => console.log('Screenshot API on :3000'));
v2: Browser Pool for Performance
Reuse browser instances across requests with generic-pool:
npm install generic-pool
const genericPool = require('generic-pool');
const { chromium } = require('playwright');
const browserPool = genericPool.createPool({
create: async () => chromium.launch({
headless: true,
args: ['--disable-dev-shm-usage', '--no-sandbox', '--disable-setuid-sandbox']
}),
destroy: async (browser) => browser.close(),
}, {
min: 2, // keep 2 browsers warm
max: 6, // max 6 concurrent
acquireTimeoutMillis: 15000,
idleTimeoutMillis: 30000,
testOnBorrow: true,
});
async function takeScreenshot(url, options = {}) {
const browser = await browserPool.acquire();
try {
const context = await browser.newContext({
viewport: { width: options.width ?? 1280, height: options.height ?? 800 },
userAgent: 'Mozilla/5.0 (compatible; ScreenshotBot/1.0)',
});
const page = await context.newPage();
await page.goto(url, { waitUntil: 'networkidle', timeout: 30000 });
if (options.waitForSelector) await page.waitForSelector(options.waitForSelector);
const buf = await page.screenshot({ fullPage: options.fullPage ?? false, type: options.format ?? 'png' });
await context.close();
return buf;
} finally {
await browserPool.release(browser);
}
}
// Express endpoint using the pool
app.get('/screenshot', async (req, res) => {
try {
const buf = await takeScreenshot(req.query.url, {
width: +req.query.width || 1280,
height: +req.query.height || 800,
fullPage: req.query.fullPage === 'true',
format: req.query.format || 'png',
});
res.setHeader('Content-Type', `image/${req.query.format || 'png'}`);
res.send(buf);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
v3: Production Setup with BullMQ + S3 Caching
For high traffic, offload screenshot jobs to a background queue and cache results in S3 to avoid re-capturing the same URL repeatedly:
const { Queue, Worker } = require('bullmq');
const { S3Client, PutObjectCommand, GetObjectCommand, HeadObjectCommand } = require('@aws-sdk/client-s3');
const crypto = require('crypto');
const Redis = require('ioredis');
const redis = new Redis({ host: process.env.REDIS_HOST });
const s3 = new S3Client({ region: process.env.AWS_REGION });
const BUCKET = process.env.S3_BUCKET;
const TTL_S = 3600; // cache for 1 hour
// Generate a deterministic cache key from request params
function cacheKey(params) {
return crypto.createHash('md5').update(JSON.stringify(params)).digest('hex');
}
async function getFromCache(key) {
try {
await s3.send(new HeadObjectCommand({ Bucket: BUCKET, Key: `screenshots/${key}.png` }));
return `https://${BUCKET}.s3.amazonaws.com/screenshots/${key}.png`;
} catch { return null; }
}
async function saveToCache(key, buffer) {
await s3.send(new PutObjectCommand({
Bucket: BUCKET, Key: `screenshots/${key}.png`,
Body: buffer, ContentType: 'image/png',
CacheControl: `public, max-age=${TTL_S}`,
}));
return `https://${BUCKET}.s3.amazonaws.com/screenshots/${key}.png`;
}
// BullMQ queue for async jobs
const screenshotQueue = new Queue('screenshots', { connection: redis });
// Worker
new Worker('screenshots', async job => {
const { url, params, key } = job.data;
const buf = await takeScreenshot(url, params);
return saveToCache(key, buf);
}, { connection: redis, concurrency: 4 });
// Async endpoint: enqueue and return job ID
app.post('/screenshot/async', async (req, res) => {
const { url, ...params } = req.body;
if (!url) return res.status(400).json({ error: 'url required' });
const key = cacheKey({ url, ...params });
const cached = await getFromCache(key);
if (cached) return res.json({ status: 'cached', url: cached });
const job = await screenshotQueue.add('capture', { url, params, key });
res.json({ status: 'queued', jobId: job.id });
});
// Poll for result
app.get('/screenshot/result/:jobId', async (req, res) => {
const job = await screenshotQueue.getJob(req.params.jobId);
if (!job) return res.status(404).json({ error: 'job not found' });
const state = await job.getState();
if (state === 'completed') return res.json({ status: 'done', url: job.returnvalue });
if (state === 'failed') return res.status(500).json({ status: 'failed', error: job.failedReason });
res.json({ status: state });
});
Rate Limiting
const rateLimit = require('express-rate-limit');
const RedisStore = require('rate-limit-redis').default;
app.use('/screenshot', rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 20, // 20 requests per minute per IP
standardHeaders: 'draft-7',
legacyHeaders: false,
store: new RedisStore({ client: redis, prefix: 'rl:' }),
handler: (req, res) => res.status(429).json({
error: 'Too many requests',
retryAfter: Math.ceil(req.rateLimit.resetTime / 1000)
})
}));
Build vs Buy: The Real Cost
Before building, calculate the true cost of a self-hosted screenshot service:
| Cost factor | Self-hosted | SnapAPI |
|---|---|---|
| Server (2 vCPU, 4GB) | ~$20–40/mo | Included |
| Chromium memory (per instance) | 200–400 MB | Included |
| S3 storage for cached screenshots | ~$0.023/GB | Optional |
| SSL, monitoring, alerts | DIY | Included |
| Browser crash recovery | DIY watchdog | Included |
| Stealth / anti-bot bypass | Complex setup | stealth: true |
| Engineering maintenance | Ongoing | Zero |
| First 200 requests/month | Server cost | Free |
Ship your screenshot feature today
Replace this entire guide with one API call. SnapAPI handles browser pool, caching, rate limiting, and stealth. 200 free screenshots/month.
Try SnapAPI Free →