Node.js Playwright API Development April 5, 2026

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'));
Problem with v1: A new browser is launched for every request. Playwright takes 1–3 seconds to start. Under concurrent load, you'll exhaust server memory quickly.

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 factorSelf-hostedSnapAPI
Server (2 vCPU, 4GB)~$20–40/moIncluded
Chromium memory (per instance)200–400 MBIncluded
S3 storage for cached screenshots~$0.023/GBOptional
SSL, monitoring, alertsDIYIncluded
Browser crash recoveryDIY watchdogIncluded
Stealth / anti-bot bypassComplex setupstealth: true
Engineering maintenanceOngoingZero
First 200 requests/monthServer costFree
Rule of thumb: Build if you need full control, custom browser behaviour, or capture volume that makes the SnapAPI pricing uneconomical at scale (>50K/mo). Buy if you want to ship in an hour and never think about browser infrastructure.

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 →