Recommended Free Tools
Build a screenshot API in Node.js by accepting a validated URL or image data URI, rendering it in an isolated Playwright page, and returning either image bytes or a base64 string. The example below implements a local POST /screenshot endpoint, including input checks, timeouts, full-page capture, and both response formats. It is a useful starting point, not a safe public service until you add authentication, SSRF defenses, and resource limits.
What the endpoint accepts and returns
A single endpoint can support two different inputs: a web page URL, or a base64-encoded image supplied as a data URI. Keep the input types explicit so the server knows whether to navigate a browser to a remote site or render a supplied image in a controlled document.
| Field | Purpose | Example or default |
|---|---|---|
url |
Target page to navigate to. Use HTTPS for normal requests. | https://example.com |
image |
Image data URI to render instead of navigating to a URL. | data:image/png;base64,... |
type |
Screenshot encoding supported by the browser library. | png by default; jpeg also supported here. |
fullPage |
For URL captures, capture beyond the viewport. | false by default |
quality |
JPEG quality from 0 through 100; ignored for PNG. | 80 in the example |
viewport |
Browser viewport dimensions. | {"width":1280,"height":800} |
waitForSelector |
Optional application-specific readiness condition. | For example, #report-ready |
response |
Choose JSON with base64 or a raw image response. | base64 by default; binary for bytes |
Playwright’s screenshot API returns a buffer and supports capture settings such as image type, quality, scale, and path. Its screenshot documentation notes that the API accepts parameters for image format, clip area, quality, and more. See Playwright Page API and Playwright Screenshots. The implementation below deliberately restricts output to PNG and JPEG: do not promise WebP unless the renderer you choose supports it.
Install Node.js, Express, and Playwright
Use a current Node.js LTS release and create a small project. Playwright’s browser must also be installed on the host; its package and browser binaries are separate setup concerns.
Quick wins for a faster PC:
Scan for outdated or missing drivers - takes under a minuteDriver Scan →Repair Windows errors before they cause bigger problemsFix Now →#1 Best Overall
-
Create the project and initialize npm:
mkdir screenshot-api cd screenshot-api npm init -y npm install express playwright npx playwright install chromium -
Add
"type": "module"topackage.jsonso the server can use ES modules. For deployment, install the required system dependencies for Chromium in your chosen environment as well. -
Save the following as
server.js. It supports URL and image requests, per-request pages, bounded request sizes, navigation and screenshot timeouts, and either binary or JSON output.
Runnable JavaScript server
import express from 'express';
import { chromium } from 'playwright';
const app = express();
const PORT = Number(process.env.PORT || 3000);
const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
const MAX_VIEWPORT = 3000;
const NAV_TIMEOUT_MS = 20_000;
const SCREENSHOT_TIMEOUT_MS = 15_000;
app.use(express.json({ limit: '7mb' }));
const browser = await chromium.launch({ headless: true });
function httpError(status, message) {
const error = new Error(message);
error.status = status;
return error;
}
function validViewport(value) {
if (value === undefined) return { width: 1280, height: 800 };
const { width, height } = value ?? {};
if (!Number.isInteger(width) || !Number.isInteger(height) ||
width < 1 || height < 1 || width > MAX_VIEWPORT || height > MAX_VIEWPORT) {
throw httpError(400, `viewport width and height must be integers from 1 to ${MAX_VIEWPORT}`);
}
return { width, height };
}
function parseImageDataUri(value) {
if (typeof value !== 'string') throw httpError(400, 'image must be a data URI');
const match = value.match(/^data:(image/(?:png|jpeg|webp));base64,([A-Za-z0-9+/]*={0,2})$/);
if (!match) throw httpError(400, 'image must be a base64 PNG, JPEG, or WebP data URI');
const [, mediaType, encoded] = match;
if (!encoded || encoded.length % 4 !== 0) throw httpError(400, 'image base64 is malformed');
const bytes = Buffer.from(encoded, 'base64');
if (!bytes.length || bytes.length > MAX_IMAGE_BYTES || bytes.toString('base64') !== encoded) {
throw httpError(400, 'image is empty, oversized, or malformed');
}
return { mediaType, dataUri: value };
}
function parseTargetUrl(value) {
if (typeof value !== 'string' || value.length > 2048) {
throw httpError(400, 'url must be a string no longer than 2048 characters');
}
let target;
try { target = new URL(value); } catch { throw httpError(400, 'url is not valid'); }
if (!['http:', 'https:'].includes(target.protocol)) {
throw httpError(400, 'url must use http or https');
}
if (target.username || target.password) throw httpError(400, 'URLs with embedded credentials are not accepted');
return target.href;
}
app.post('/screenshot', async (req, res) => {
let context;
try {
const body = req.body ?? {};
const hasUrl = body.url !== undefined;
const hasImage = body.image !== undefined;
if (hasUrl === hasImage) throw httpError(400, 'provide exactly one of url or image');
const viewport = validViewport(body.viewport);
const type = body.type ?? 'png';
if (!['png', 'jpeg'].includes(type)) throw httpError(400, 'type must be png or jpeg');
const response = body.response ?? 'base64';
if (!['base64', 'binary'].includes(response)) throw httpError(400, 'response must be base64 or binary');
const fullPage = body.fullPage ?? false;
if (typeof fullPage !== 'boolean') throw httpError(400, 'fullPage must be boolean');
const quality = body.quality ?? 80;
if (type === 'jpeg' && (!Number.isInteger(quality) || quality < 0 || quality > 100)) {
throw httpError(400, 'quality must be an integer from 0 to 100 for jpeg');
}
if (body.waitForSelector !== undefined && typeof body.waitForSelector !== 'string') {
throw httpError(400, 'waitForSelector must be a string');
}
context = await browser.newContext({ viewport });
const page = await context.newPage();
page.setDefaultTimeout(SCREENSHOT_TIMEOUT_MS);
if (hasUrl) {
const url = parseTargetUrl(body.url);
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: NAV_TIMEOUT_MS });
if (body.waitForSelector) await page.locator(body.waitForSelector).waitFor({ state: 'visible' });
} else {
const { dataUri } = parseImageDataUri(body.image);
await page.setContent(`<!doctype html><html><head><style>
html,body{margin:0;min-height:100%;background:transparent}
body{display:grid;place-items:start center}
img{display:block;max-width:100%;height:auto}
</style></head><body><img id="source" src="${dataUri}"></body></html>`,
{ waitUntil: 'load', timeout: NAV_TIMEOUT_MS });
await page.locator('#source').evaluate(img => img.decode());
}
const image = await page.screenshot({
type,
fullPage: hasUrl ? fullPage : false,
...(type === 'jpeg' ? { quality } : {}),
timeout: SCREENSHOT_TIMEOUT_MS
});
res.set('X-Image-Type', `image/${type}`);
if (response === 'binary') {
res.type(`image/${type}`).send(image);
} else {
res.json({ type, encoding: 'base64', data: image.toString('base64') });
}
} catch (error) {
const status = Number.isInteger(error.status) ? error.status : 502;
res.status(status).json({ error: error.message || 'screenshot failed' });
} finally {
if (context) await context.close().catch(() => {});
}
});
app.listen(PORT, () => console.log(`Screenshot API listening on http://localhost:${PORT}`));
async function shutdown() {
await browser.close();
process.exit(0);
}
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
Run it with node server.js. Each request gets a fresh browser context, which prevents cookies and local storage from carrying over between requests. The browser itself is shared to avoid launching a process for every capture; the page and context are closed after each request. This simple version does not set an authentication layer or implement an SSRF-safe network policy, so keep it local or behind a trusted boundary until you add those controls.
Call the endpoint
URL capture as JSON base64
curl -X POST http://localhost:3000/screenshot
-H 'Content-Type: application/json'
-d '{"url":"https://example.com","type":"png","fullPage":true,"response":"base64"}'
The JSON response has a type, an encoding set to base64, and a data field. To save its contents from a shell, pipe the JSON through a JSON parser and decode the data value with a base64 decoder. Base64 expands binary data, so use the binary response for larger captures or clients that can consume an image body directly.
What’s actually slowing this PC down?
Pick the symptom - the matching free tool is one click away.
Rank #2
Image capture as binary
curl -X POST http://localhost:3000/screenshot
-H 'Content-Type: application/json'
-o page.png
-d '{"url":"https://example.com","type":"png","response":"binary"}'
For binary output, the response body is PNG or JPEG bytes and the server sets the corresponding content type. The X-Image-Type header is also set for clients that need to inspect the chosen format.
Send a base64 image
curl -X POST http://localhost:3000/screenshot
-H 'Content-Type: application/json'
-d '{"image":"data:image/png;base64,iVBORw0KGgo...","type":"png","response":"base64"}'
Replace the abbreviated payload with the complete data URI. This server accepts PNG, JPEG, and WebP as source images, but outputs PNG or JPEG. It decodes the supplied bytes in Chromium and waits for the image to finish decoding before taking the screenshot.
JavaScript client
const response = await fetch('http://localhost:3000/screenshot', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
url: 'https://example.com',
type: 'jpeg',
quality: 85,
viewport: { width: 1440, height: 900 },
response: 'binary'
})
});
if (!response.ok) throw new Error(await response.text());
const bytes = Buffer.from(await response.arrayBuffer());
await import('node:fs/promises').then(fs => fs.writeFile('page.jpg', bytes));
Python client
import requests
response = requests.post(
'http://localhost:3000/screenshot',
json={
'url': 'https://example.com',
'type': 'png',
'fullPage': True,
'response': 'binary',
},
timeout=40,
)
response.raise_for_status()
with open('page.png', 'wb') as image_file:
image_file.write(response.content)
Readiness, capture settings, and image behavior
Choose what “ready” means
The sample navigates with waitUntil: 'domcontentloaded', which waits for the document to be parsed, not for every image, ad, font, or application request to finish. Pages that render important content later should pass a stable waitForSelector that appears only after the relevant content is ready. A selector wait can also fail when the target is absent, misspelled, hidden, or delayed beyond the timeout; return a clear error instead of silently pretending the capture is complete.
Waiting for every network request to stop can be a poor universal default: analytics, polling, and chat connections may keep a page active indefinitely. Define and document the readiness condition your service promises. For controlled pages, an application-specific selector is often more meaningful than a generic delay.
Viewport and full-page captures
The viewport determines the layout the site renders, not just how much of an existing layout is visible. A narrow width can trigger a mobile breakpoint; a high device scale factor can change output pixel dimensions. The sample fixes a viewport and excludes device scale configuration for simplicity. Add a validated scale setting if clients need retina-sized output.
fullPage is useful for long documents, but a page with an extremely tall document can consume substantial memory and produce a very large image. Enforce maximum page dimensions or pixel counts in production. For a rectangular crop, Puppeteer documents a clip option; Playwright also supports screenshot parameters for areas and elements. Add clipping only with validation that the requested rectangle is within allowed bounds.
Base64 input is data, not proof of an image
A URI such as data:image/png;base64,... combines a media type and an encoded payload. This server restricts the declared type and performs basic base64 validation, but a matching prefix does not guarantee the bytes are a valid image. For a public API, inspect or decode the actual file format using a trusted image decoder before rendering, and impose a byte limit after decoding. The general data URI scheme describes the representation; treat caller-provided content as untrusted.
Raw base64 or a data URI response?
The endpoint returns raw base64 inside JSON, alongside a separate type field. That avoids repeating the media-type prefix in the string. A consumer that needs a browser-ready value can assemble data:image/png;base64, plus the returned string. Binary output is usually simpler for file downloads, object storage, or image-processing pipelines.
The Tool Desk
Outbyte Driver Updater FREEScan for outdated or missing drivers - takes under a minuteDriver Scan →Outbyte PC Repair FREERepair Windows errors before they cause bigger problemsFix Now →Rank #4
Security before exposing the service
A screenshot service that visits caller-selected URLs is an SSRF-sensitive browser service. A protocol check alone does not make it safe: a permitted hostname may resolve to a private address, or redirect to one. The sample is intentionally not a complete public-service security boundary.
- Block internal destinations. Deny localhost, loopback, link-local, private, and cloud metadata IP ranges. Enforce this at the network layer and validate every redirect and DNS resolution; do not rely only on checking the original URL string.
- Authenticate and authorize callers. Rate-limit by account or key and avoid leaving an unauthenticated endpoint open to arbitrary capture workloads.
- Bound all expensive inputs. Limit body size, URL length, decoded image bytes, viewport area, full-page height, navigation time, screenshot time, and concurrency.
- Isolate browser state. Use a fresh context for each untrusted request, deny unnecessary permissions, and avoid shared authentication state or persistent profiles.
- Control network access. Decide whether pages may load third-party scripts, fonts, images, and other resources. Each allowed resource affects capture fidelity and can expose network access to the target page.
- Protect logs. Record a request ID, sanitized target metadata, duration, output type, and failure class; do not log credentials, full image payloads, or sensitive headers.
- Recycle unhealthy workers. Close pages and contexts on every path, monitor browser process health, and replace workers that stop responding.
These are engineering controls for caller-controlled browser navigation, not guarantees provided automatically by Playwright or Puppeteer. Puppeteer’s screenshot methods and options are documented at Page.screenshot and ScreenshotOptions; the library primitives do not by themselves define a complete security policy.
Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Reliability, performance, and cost trade-offs
Launching a fresh browser for every request is straightforward but typically wastes startup work; keeping one browser process alive and creating isolated contexts, as in the example, is a better baseline. It still needs operational limits: full-page captures, complex pages, and large output images use more memory and CPU than a small viewport capture. The documentation cited here establishes capture capabilities, not comparative latency or memory benchmarks, so benchmark representative target pages on the host and browser version you deploy.
Use a bounded queue or concurrency limit rather than allowing incoming requests to create unlimited simultaneous pages. Set a maximum number of active captures, reject or queue excess work, and consider a worker pool if browser failures must not affect unrelated requests. For workflows that tolerate asynchronous completion, jobs and a status endpoint can protect request workers from long-running captures. Measure duration and failure classes in your environment before setting service-level promises.
Best Value
Cost depends on your hosting, browser runtime, workload, and output storage; there is no universal per-capture cost established by the library documentation. Keep request limits visible in the API contract so a client cannot submit an unbounded full-page capture or a multi-megabyte image without warning.
Common errors and practical fixes
| Symptom | Likely cause | Fix |
|---|---|---|
400 provide exactly one of url or image |
Both inputs or neither input was provided. | Send exactly one field in the JSON body. |
400 url must use http or https |
The URL has an unsupported scheme such as file:. |
Use an HTTP(S) target. Keep a separate, controlled mechanism for local content rather than exposing file navigation. |
400 image is empty, oversized, or malformed |
The data URI is incomplete, improperly padded, or exceeds the configured decoded-byte limit. | Send a complete image data URI and check its decoded size and base64 encoding. |
| Navigation returns an error or times out | The host is unreachable, redirects unexpectedly, or does not complete within the navigation limit. | Check reachability from the server, inspect redirect behavior, and tune the timeout within an overall request deadline. |
| Screenshot fails after navigation | The selector never becomes visible, the page is still changing, or the browser worker is unhealthy. | Use a selector tied to actual page readiness, verify it exists, and recycle a stuck worker after recording the failure. |
| Output is clipped or unexpectedly enormous | The viewport differs from expectations or full-page capture includes a very tall document. | Set explicit viewport dimensions; disable full-page capture or enforce a document-height/pixel limit. |
| Browser executable or shared-library error at startup | Chromium was not installed, or the deployment image lacks its required system dependencies. | Install Playwright’s Chromium in the build/deploy environment and include the platform dependencies required by that environment. |
Or skip the browser setup
If you need screenshot capture without managing Chromium, contexts, and workers, ScreenshotNeo is a website screenshot API and MCP server. Its API takes one GET request with a URL and returns PNG, JPEG, WebP, or PDF. For example:
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
See the ScreenshotNeo API documentation for parameters and setup. It accepts cookie and consent banners before capture and removes more than 60 known consent platforms, newsletter popups, and chat widgets; each step can be turned off. Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed, and the response includes X-Page-Verdict and X-Billed headers. Its MCP server provides take_screenshot, get_page_info, and capture_pdf for Claude, Cursor, and other MCP clients. The free plan includes 1,000 shots per month with no card; paid plans start at $5 for 3,000 shots. Sign up for free ScreenshotNeo access.
Frequently Asked Questions
Can Puppeteer return a screenshot directly as a base64 string?
Yes. Puppeteer’s page.screenshot({ encoding: 'base64' }) returns a promise resolving to a string; without that encoding option, its screenshot API returns binary image data.
Can this API render an image without navigating to a website?
Yes. Send the image as a supported base64 data URI in the image field; the endpoint renders it in its own page and captures that page.
Quick Recap
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.

