Free tools Windows power users keep installed

One-click scans. No signup required.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Some links on this page are affiliate links: if you buy through them we may earn a commission, at no extra cost to you.

Build the API as a Node.js service that accepts either a URL or an image data URI, captures it with a headless browser, and returns either image bytes or base64 in JSON. The example below uses Express and Playwright, validates the request before opening a page, creates an isolated browser context per request, and closes it on success or failure. Treat caller-supplied URLs as a security boundary: before exposing the endpoint to untrusted callers, add network-level SSRF protections and resource limits.

Choose a request and response contract

Keep the two input modes mutually exclusive: send url to capture a web page, or image containing a data URI to capture an uploaded image. A useful POST /screenshot request can also take type, fullPage, clip, quality, omitBackground, viewport, and an application-level readiness selector. Validate options and limits before launching or navigating a page.

For output, choose one documented representation. An image response sends the screenshot bytes with the correct content type; a JSON response can carry a raw base64 string and its MIME type. Raw base64 is convenient in JSON and avoids duplicating the media-type prefix; a data URI is useful when the recipient needs a directly usable src. Do not leave clients to guess which form they received.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

The example uses Playwright and deliberately supports PNG and JPEG. Playwright returns a buffer from page.screenshot(); its API documents screenshot format, quality, scale, and path behavior. See Playwright Page API. Puppeteer is also suitable: its screenshot method returns a Promise<string> for encoding: 'base64' or a Promise<Uint8Array> for binary output.

Install the Node.js service

Use a maintained Node.js release supported by your deployment environment. Create a project, install dependencies, and install the browser binaries Playwright needs:

npm init -y
npm install express playwright
npx playwright install chromium

Save the following as server.js, then run it with node server.js. It listens on port 3000 by default. The example is a starting point for a controlled service, not a complete public-Internet SSRF defense; the production safeguards below are required before accepting arbitrary targets.

const express = require('express');
const { chromium } = require('playwright');

const app = express();
app.use(express.json({ limit: '8mb' }));

const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
const MAX_URL_LENGTH = 2048;
const allowedImageTypes = new Set(['image/png', 'image/jpeg', 'image/webp']);
const allowedOutputTypes = new Set(['png', 'jpeg']);
let browserPromise;

function getBrowser() {
  if (!browserPromise) browserPromise = chromium.launch({ headless: true });
  return browserPromise;
}

function badRequest(message) {
  const error = new Error(message);
  error.status = 400;
  return error;
}

function parseImage(value) {
  if (typeof value !== 'string') throw badRequest('image must be a data URI');
  const match = value.match(/^data:(image/(?:png|jpeg|webp));base64,([A-Za-z0-9+/]+={0,2})$/);
  if (!match || !allowedImageTypes.has(match[1])) {
    throw badRequest('image must be a base64 PNG, JPEG, or WebP data URI');
  }
  const bytes = Buffer.from(match[2], 'base64');
  if (!bytes.length || bytes.length > MAX_IMAGE_BYTES) {
    throw badRequest('decoded image is empty or exceeds the 5 MB limit');
  }
  // Reject strings that Buffer decoded permissively despite invalid base64 syntax.
  if (bytes.toString('base64').replace(/=+$/, '') !== match[2].replace(/=+$/, '')) {
    throw badRequest('image contains malformed base64');
  }
  return { mime: match[1], dataUri: value, bytes };
}

function parseOptions(body) {
  const type = body.type ?? 'png';
  if (!allowedOutputTypes.has(type)) throw badRequest('type must be png or jpeg');
  const fullPage = body.fullPage ?? false;
  if (typeof fullPage !== 'boolean') throw badRequest('fullPage must be boolean');
  const width = body.viewport?.width ?? 1280;
  const height = body.viewport?.height ?? 800;
  if (!Number.isInteger(width) || !Number.isInteger(height) ||
      width < 1 || height < 1 || width > 3000 || height > 3000) {
    throw badRequest('viewport width and height must be integers from 1 to 3000');
  }
  const quality = body.quality;
  if (quality !== undefined && (type !== 'jpeg' || !Number.isInteger(quality) || quality < 0 || quality > 100)) {
    throw badRequest('quality is supported only for jpeg and must be an integer from 0 to 100');
  }
  let clip;
  if (body.clip !== undefined) {
    const c = body.clip;
    if (fullPage || !c || !['x', 'y', 'width', 'height'].every(k => Number.isFinite(c[k])) ||
        c.width <= 0 || c.height <= 0 || c.x < 0 || c.y < 0 ||
        c.x + c.width > width || c.y + c.height > height) {
      throw badRequest('clip needs positive x, y, width, height within the viewport and cannot combine with fullPage');
    }
    clip = { x: c.x, y: c.y, width: c.width, height: c.height };
  }
  if (body.omitBackground !== undefined && typeof body.omitBackground !== 'boolean') {
    throw badRequest('omitBackground must be boolean');
  }
  if (body.waitForSelector !== undefined &&
      (typeof body.waitForSelector !== 'string' || body.waitForSelector.length > 200)) {
    throw badRequest('waitForSelector must be a string no longer than 200 characters');
  }
  return { type, fullPage, width, height, quality, clip, omitBackground: body.omitBackground ?? false };
}

function validatePublicHttpsUrl(value) {
  if (typeof value !== 'string' || value.length > MAX_URL_LENGTH) {
    throw badRequest('url must be a string no longer than 2048 characters');
  }
  let target;
  try { target = new URL(value); } catch { throw badRequest('url is not valid'); }
  if (target.protocol !== 'https:' || target.username || target.password) {
    throw badRequest('url must be HTTPS and cannot contain embedded credentials');
  }
  // This basic hostname check is not a substitute for DNS/IP and egress controls.
  const host = target.hostname.toLowerCase();
  if (host === 'localhost' || host.endsWith('.localhost') || host.endsWith('.local')) {
    throw badRequest('local hostnames are not allowed');
  }
  return target.href;
}

app.post('/screenshot', async (req, res) => {
  let context;
  try {
    const body = req.body;
    if (!body || typeof body !== 'object' || Array.isArray(body)) throw badRequest('request body must be a JSON object');
    const hasUrl = body.url !== undefined;
    const hasImage = body.image !== undefined;
    if (hasUrl === hasImage) throw badRequest('send exactly one of url or image');

    const options = parseOptions(body);
    const target = hasUrl ? validatePublicHttpsUrl(body.url) : null;
    const image = hasImage ? parseImage(body.image) : null;
    const browser = await getBrowser();
    context = await browser.newContext({ viewport: { width: options.width, height: options.height } });
    const page = await context.newPage();
    page.setDefaultNavigationTimeout(15000);
    page.setDefaultTimeout(5000);

    if (target) {
      await page.goto(target, { waitUntil: 'load', timeout: 15000 });
      if (body.waitForSelector) await page.locator(body.waitForSelector).waitFor({ state: 'visible', timeout: 5000 });
    } else {
      await page.setContent('<!doctype html><html><body style="margin:0"><img id="source" style="display:block;max-width:100%;max-height:100vh"></body></html>');
      await page.locator('#source').evaluate((img, src) => { img.src = src; }, image.dataUri);
      await page.locator('#source').evaluate(img => img.decode());
    }

    const shotOptions = {
      type: options.type,
      fullPage: options.fullPage,
      omitBackground: options.omitBackground
    };
    if (options.clip) shotOptions.clip = options.clip;
    if (options.quality !== undefined) shotOptions.quality = options.quality;
    const bytes = target ? await page.screenshot(shotOptions) :
      await page.locator('#source').screenshot({ type: options.type, ...(options.quality !== undefined ? { quality: options.quality } : {}) });

    const mime = options.type === 'jpeg' ? 'image/jpeg' : 'image/png';
    if (req.query.response === 'image') {
      res.set('Content-Type', mime).send(bytes);
    } else {
      res.json({ type: mime, encoding: 'base64', data: bytes.toString('base64') });
    }
  } catch (error) {
    const status = error.status || (error.name === 'TimeoutError' ? 504 : 502);
    res.status(status).json({ error: error.status ? 'invalid_request' : 'capture_failed', message: error.message });
  } finally {
    if (context) await context.close().catch(() => {});
  }
});

app.listen(process.env.PORT || 3000, () => {
  console.log(`Screenshot API listening on port ${process.env.PORT || 3000}`);
});

The image mode checks the data-URI shape, decodes it as bytes, caps decoded size, loads it into a local document, and waits for image decoding before capture. A base64-looking string is not proof that its contents are a valid image; for stricter deployments, also inspect file signatures or decode with a hardened image library and reject unexpected dimensions. The accepted data URI convention is data:image/png;base64,<payload>; see the data URI reference.

What’s actually slowing this PC down?

Pick the symptom - the matching free tool is one click away.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Call the endpoint

Send one of the two inputs. This example requests a full-page PNG and receives JSON containing raw base64:

curl -X POST http://localhost:3000/screenshot 
  -H 'Content-Type: application/json' 
  -d '{"url":"https://example.com","type":"png","fullPage":true}'

To receive the binary screenshot instead, add ?response=image and write the response to a file:

curl -X POST 'http://localhost:3000/screenshot?response=image' 
  -H 'Content-Type: application/json' 
  -d '{"url":"https://example.com","type":"jpeg","quality":85}' 
  -o page.jpg

For an image request, put a data URI in image instead of url:

{"image":"data:image/png;base64,iVBORw0KGgo...","type":"png"}

Keep the payload within the API’s body and decoded-image limits. In a real client, read the entire returned JSON rather than printing a large base64 value to a terminal.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Adapt capture behavior without weakening limits

Full page and a selected region

fullPage: true captures the full scrollable page in the URL mode. It can produce a very tall image and take more memory than a viewport capture, so impose a maximum page height or output-byte limit in production. The example’s clip uses viewport coordinates and cannot be combined with fullPage; it rejects coordinates outside the configured viewport. To capture a particular element, use Playwright’s locator screenshot method, such as page.locator('.receipt').screenshot(), after verifying that the selector is safe and that the element exists. Playwright documents both full-page and element screenshots in its screenshots guide.

Readiness and timeouts

waitUntil: 'load' is a defined, bounded baseline, not a guarantee that a client-rendered page is finished. If the target application has a reliable ready marker, pass an application-level selector and wait for it, as the sample does. The selector wait has its own timeout. A fixed delay can be useful for known animation or delayed rendering, but it adds latency to every request and still does not prove the page is ready. Network-idle conditions can also be unsuitable for pages that keep connections open.

Formats, quality, transparency, and viewport

Playwright screenshot options include type, quality, scale, and path behavior. In this service the response formats are PNG and JPEG; quality is accepted only for JPEG. Transparent background handling is exposed through omitBackground, although it is relevant only when the page background can be omitted and the chosen output supports transparency. The endpoint caps viewport dimensions at 3000 by 3000 to constrain input, but operators should set limits appropriate to their memory budget. Device scale can be added as a separate validated option if callers need retina-sized output; it multiplies the raster dimensions and cost of a capture.

Protect a URL screenshot endpoint from abuse

A browser that navigates to caller-controlled URLs can reach more than public websites. The hostname check in the sample blocks some obvious local names, but is deliberately not presented as complete SSRF protection: an attacker may use IP literals, DNS answers that resolve to private addresses, redirects, or other network paths. Use an allowlist where possible. Otherwise enforce DNS/IP policy for every resolved address and redirect, and deny loopback, private, link-local, and cloud metadata ranges at the network egress layer. Do not rely on URL string checks alone.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
  • Set an HTTP request-body cap, maximum URL length, decoded image bytes, viewport dimensions, page height, capture time, and output size. Reject invalid values before allocating browser resources.
  • Use an isolated context for each untrusted request. The example creates a fresh context, so cookies and page state are not intentionally shared; close it in finally even when navigation or capture fails.
  • Decide whether remote fonts, scripts, images, and other resources are allowed. They affect fidelity and can expose information or consume bandwidth; block unnecessary request types or use controlled egress rules.
  • Limit concurrent jobs and queue excess work. Reuse a browser process or managed browser pool rather than launching a process for each request, and recycle workers that become unhealthy.
  • Require authentication and rate limits for a public endpoint. Log a request ID, sanitized target metadata, duration, output format, and failure class, but never log credentials or image contents.

These are engineering safeguards for a browser service that processes untrusted input, not guarantees supplied by either browser library. A screenshot API needs an explicit policy for what it will visit and how much work it will perform.

Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Support on Ko-Fi

Handle failures, latency, and cost

Return structured client errors for malformed input and separate them from capture failures. The sample reports 400 for validation, 504 for Playwright timeouts, and 502 for other capture errors. In a production contract, define stable error codes rather than making clients parse browser exception text; avoid returning internal hostnames, stack traces, or sensitive details.

Navigation and screenshot work consumes browser CPU and memory, particularly for large pages, full-page captures, high-density viewports, and image-heavy sites. Concurrency caps, bounded timeouts, request-size limits, and output-size limits make workloads more predictable. The documentation establishes screenshot capabilities, not universal latency or memory benchmarks: measure on your own pages, browser version, hardware, and concurrency before setting service-level promises. Cache only when the URL, relevant headers or cookies, viewport, format, and capture options make reuse correct; a cached result should not accidentally cross user or authorization boundaries.

Or skip the browser setup

ScreenshotNeo is a website screenshot API and MCP server from Yorker Media. Its single GET request can return PNG, JPEG, WebP, or PDF. Before capture, it accepts cookie/consent banners like a visitor and removes more than 60 known consent platforms, newsletter popups, and chat widgets; each cleanup step can be turned off. Bot checks/CAPTCHAs, blank pages, timeouts, failed loads, and cache hits are not billed, and the response reports the page verdict and billing status in headers. Its MCP server exposes take_screenshot, get_page_info, and capture_pdf for Claude, Cursor, and other MCP clients.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Node.js one-call example: see the ScreenshotNeo API documentation for setup and options.

const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://example.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);

Free includes 1,000 screenshots per month with no card; paid plans start at $5 for 3,000 shots. Sign up for 1,000 free screenshots a month with no card.

Common implementation errors

  • The browser executable is missing: install Chromium with npx playwright install chromium in the deployment image as well as locally.
  • Navigation times out: the site may be slow, inaccessible from your network, or waiting on resources. Keep a finite timeout, decide whether a different readiness condition is appropriate, and return a timeout error rather than holding a worker indefinitely.
  • A selector wait never completes: check that the selector belongs to the page’s actual rendered DOM and appears in the active viewport state. Set a bounded selector timeout and return a clear error.
  • Base64 input is rejected or the image is blank: include the complete data URI prefix, use an allowed image MIME type, send valid base64, and check the decoded byte cap. Verify that the file is actually an image rather than relying on its filename.
  • JPEG quality or clip validation fails: quality applies only to JPEG in this contract; clip dimensions must be positive and remain within the viewport, and clip cannot accompany full-page capture.
  • Memory or queue pressure rises: lower the maximum viewport and page height, limit concurrent captures, impose output limits, and ensure contexts close after errors. Avoid treating a shared page with leftover cookies as isolated.

When to use Puppeteer instead

Choose the browser library that fits the project’s existing automation stack and supported browser-engine needs. Both Playwright and Puppeteer expose screenshot capture; there is no benchmark here that establishes one as universally faster or lighter. Puppeteer’s options include captureBeyondViewport, clip, encoding, fullPage, omitBackground, path, quality, and type, as listed in its ScreenshotOptions reference. In Puppeteer, request base64 directly with await page.screenshot({ encoding: 'base64' }); omit that encoding for binary output, then make the HTTP response contract explicit just as in the Playwright implementation.

Frequently Asked Questions

Should a screenshot endpoint return base64 or an image file?

Return base64 inside JSON when the consumer needs JSON transport and the payload is modest; use an image response for binary clients or larger captures. Document whether the JSON value is raw base64 or a data URI.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Can I safely expose a URL screenshot endpoint to the public internet as written?

No. The example has basic input checks, but its hostname test is not complete SSRF protection. Add DNS/IP and redirect-aware egress controls, authentication, rate limits, and resource caps before public deployment.

Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.