Build a Headless Screenshot Service
A complete screenshot service: browser pool, render timeouts, viewport presets, full-page capture, safe URL handling, and output tests.
Project goal
Render a page in a headless browser and return a PNG for a requested viewport, with timeouts and isolation so a hostile page cannot hang the service.
Final features
- Browser pool
- Isolated context
- Timed navigation
- Capture
- URL guard
Architecture
A pool of browser instances serves requests one page per context. Each request sets a viewport, navigates with a timeout, waits for a load signal, and captures a screenshot. A per-request context isolates cookies and storage.
Prerequisites
- Node.js 20 LTS or later
- A Chromium build compatible with the driver
- A container or host able to run headless Chromium
Dependencies
- playwright ^1.46.0 — Browser automation and screenshots
- express ^4.21.0 — HTTP endpoint
Directory structure
screenshot-service/
├── src/pool.ts
├── src/capture.ts
├── src/viewport.ts
├── src/guard.ts
├── tests/screenshot.test.ts
└── package.json
Phases
1. Browser pool
Reuse a bounded number of browser instances across requests.
const browser = await chromium.launch();
pool.push(browser);
Expected behavior: Launching a browser per request is far too slow.
2. Isolated context
Create a fresh context per request with its own viewport.
const ctx = await browser.newContext({ viewport });
Expected behavior: Shared contexts leak cookies between users.
3. Timed navigation
Navigate with a deadline and a wait condition.
await page.goto(url, { timeout: 15000, waitUntil: 'networkidle' });
Expected behavior: A page that never finishes loading must not hang the worker.
4. Capture
Take a viewport or full-page screenshot as PNG.
const buf = await page.screenshot({ fullPage, type: 'png' });
Expected behavior: The capture mode is part of the request contract.
5. URL guard
Block internal and file URLs before navigating.
if (!isPublicHttpUrl(url)) throw new Error('blocked url');
Expected behavior: A screenshot service is a powerful SSRF target.
Testing
Capture a local static page at two viewports and assert the PNG dimensions; request a file:// URL and assert it is rejected.
Troubleshooting
Screenshots time out on heavy pages
networkidle never settles; use a load event plus an explicit delay.
Browser instances leak
Contexts were never closed; always close the context in a finally block.
Deployment
Run browsers in containers with CPU and memory limits, recycle instances periodically to avoid leaks, and expose only the screenshot endpoint publicly.
Known limitations
- Chromium only.
- No PDF output or video recording.
- Full-page capture of very long pages is memory heavy.
Project overview
A headless screenshot service is a renderer that strangers can drive, so the two problems that matter are timeouts and isolation. This build bounds every navigation and gives each request its own browser context so one page cannot affect another.
You will pool browser instances, serve one page per context, set the viewport, navigate with a timeout, wait for a load signal, capture a screenshot, and reject any URL that is not allowed.
The URL guard is the security boundary: a service that will navigate anywhere is an SSRF tool and a way to reach internal metadata endpoints, so the allowed scheme and the allowed hosts are checked before the browser sees the URL.
Isolation is the correctness boundary: a shared browser context would let cookies, storage, and even navigation state leak between requests, so each capture gets a fresh context that is thrown away afterward.
Each phase adds one property: the pool, the isolated context, the timed navigation, the capture, and the URL guard, so the service is usable and safe at every stage.
It is for teams that need link previews, visual regression images, or report snapshots, and who have discovered that a browser is a heavy, stateful dependency. The service wraps that dependency in a bounded pool so the rest of the system sees a simple image endpoint.
It is also a compact example of treating a heavyweight dependency correctly: bound its concurrency, bound its lifetime, and isolate its state, rather than hoping it behaves like a function call.
What you will build
The service provides five capabilities, each with a test that inspects the returned image or the rejection:
- Browser pool
- Isolated context
- Timed navigation
- Capture
- URL guard
A browser pool bounds concurrency; an isolated context keeps requests separate; a timed navigation prevents a hostile page from hanging the worker; a capture returns the PNG; and a URL guard blocks internal and non-HTTP targets.
The design assumes the page is hostile and slow, so every request is bounded in time and memory and runs in a context that cannot reach another request’s data.
Architecture and data model
A pool of browser instances serves requests one page per context. Each request sets a viewport, navigates with a timeout, waits for a load signal, and captures a screenshot. A per-request context isolates cookies and storage.
A pool of browser instances serves requests, one context per request, so concurrency is bounded by the pool size rather than by incoming traffic. A request that cannot get a context waits rather than spawning an unbounded number of browsers.
Each request creates a fresh context, sets the viewport, navigates with a timeout, waits for a load signal, and captures a screenshot before closing the context and releasing the browser.
Navigation uses a timeout that covers the whole page lifecycle, including network idle, so a page that keeps loading sub-resources cannot hold a worker indefinitely.
A URL guard validates the scheme and resolves the host, rejecting private and loopback addresses before the browser is asked to navigate.
Bounding concurrency at the pool rather than at the request handler is what keeps the service predictable, because it is the browser count, not the request count, that determines memory and CPU usage.
A load signal that waits for network idle is a trade: too eager produces blank images and too patient wastes the worker, so the signal and the timeout are chosen together and tested against a deliberately slow page.
Prerequisites and dependencies
You need a headless browser with its system dependencies and a container or host that can run it with resource limits.
- Node.js 20 LTS or later
- A Chromium build compatible with the driver
- A container or host able to run headless Chromium
The dependencies cover the HTTP surface and the browser automation, with the browser itself as the heavy runtime:
playwright(^1.46.0) — Browser automation and screenshotsexpress(^4.21.0) — HTTP endpoint
pnpm init
pnpm add playwright express
pnpm add -D typescript tsx vitest
Set the pool size, the navigation timeout, and the maximum viewport in configuration, because these are the knobs that bound both cost and risk, and a default that is too generous turns a screenshot into an outage.
Directory structure
Separate the pool, the context lifecycle, the navigation and wait, the capture, and the URL guard so each is testable against a local static page:
screenshot-service/
├── src/pool.ts
├── src/capture.ts
├── src/viewport.ts
├── src/guard.ts
├── tests/screenshot.test.ts
└── package.json
Phase 1: Browser pool
Reuse a bounded number of browser instances across requests.
const browser = await chromium.launch();
pool.push(browser);
Launching a browser per request is far too slow.
Pool browser instances and serve one context per request, so concurrency is bounded and a burst of requests cannot spawn an unbounded number of processes.
Phase 2: Isolated context
Create a fresh context per request with its own viewport.
const ctx = await browser.newContext({ viewport });
Shared contexts leak cookies between users.
Create a fresh context per request and destroy it afterward, because shared cookies or storage between captures is both a correctness bug and a privacy problem.
Phase 3: Timed navigation
Navigate with a deadline and a wait condition.
await page.goto(url, { timeout: 15000, waitUntil: 'networkidle' });
A page that never finishes loading must not hang the worker.
Navigate with a timeout that covers the full page lifecycle, since a page that never settles is the normal case for a hostile target and the timeout is the only defense.
Phase 4: Capture
Take a viewport or full-page screenshot as PNG.
const buf = await page.screenshot({ fullPage, type: 'png' });
The capture mode is part of the request contract.
Wait for a defined load signal before capturing, so the screenshot is deterministic rather than racing the page’s own rendering.
Phase 5: URL guard
Block internal and file URLs before navigating.
if (!isPublicHttpUrl(url)) throw new Error('blocked url');
A screenshot service is a powerful SSRF target.
Guard the URL before navigation, rejecting non-HTTP schemes and private addresses, because a service that navigates anywhere is a request-forgery tool.
Testing
Capture a local static page at two viewports and assert the PNG dimensions; request a file:// URL and assert it is rejected.
Capture a local static page at two viewports and assert the PNG dimensions, which verifies the viewport handling without depending on a remote site.
Request a file URL or an internal address and assert it is rejected by the guard, since this is the boundary that keeps the service from reaching internal resources.
Assert that a page that never settles produces a timeout error rather than hanging the test, because the timeout is the property that keeps the pool healthy.
Assert that two requests do not share cookies by having the first set a cookie and the second assert it is absent, which proves the context really is isolated.
Serve the test page from an in-process server on an ephemeral port so the suite does not depend on the network and the navigation path is exercised the same way a real request would be.
Assert too that the guard runs before the browser is used, because a guard evaluated after navigation has already made the request it was meant to prevent.
pnpm vitest run
Deployment and operations
Run browsers in containers with CPU and memory limits, recycle instances periodically to avoid leaks, and expose only the screenshot endpoint publicly.
Run browsers in containers with CPU and memory limits, and place a hard ceiling on concurrent browsers, because a runaway render can consume an entire host.
Recycle browser instances periodically, since long-lived headless browsers accumulate memory even when contexts are closed.
Expose only the screenshot endpoint publicly and keep the guard strict, because the service is a powerful primitive that an attacker would use to probe the network.
Monitor capture latency and timeout rate together, since a rising timeout rate usually means the pages are heavier rather than that the service is slow.
Pin the browser image version, because the rendering engine affects the pixels and an unpinned upgrade changes screenshots across the fleet at once.
Set the maximum viewport size in configuration and reject a larger request before rendering, because a huge canvas is a memory cost that scales with the requested area.
Recycle a browser after a fixed number of captures or a fixed age, whichever comes first, so a leak is bounded even when its rate is unknown.
Security considerations
Block private addresses and non-HTTP schemes, disable downloads, and run the browser as an unprivileged user with no host filesystem access.
Resolve the host and reject private and loopback addresses before navigation, and re-check after redirects, because a redirect is a common way to bypass a one-time URL check.
Disable downloads and extra browser features that the capture does not need, since every enabled feature is additional attack surface in a browser that renders untrusted content.
Bound the page size and the number of sub-resources, because a page that loads unbounded content can exhaust the worker’s memory.
Treat the rendered page’s content as untrusted and never evaluate page-provided scripts beyond what the capture requires, to avoid turning the renderer into an execution environment for a caller.
Continue the address check across redirects rather than only at the initial URL, because a redirect to an internal address is the standard way to defeat a single check.
Never return the page’s console output or network log to the caller by default, because those can contain secrets the page loaded during rendering.
Observability
Track capture latency, pool utilization, and failures by phase. Alert on utilization saturation, which precedes timeouts.
Track queue wait, capture latency, and timeout rate, because together they show whether the pool is sized correctly for the demand.
Chart the browser instance count and memory per instance, since a leak appears as memory growth across recycles rather than as a sudden failure.
Log rejections by reason, because a rise in guard rejections may indicate abuse or a client that needs a different allowed host.
Alert on pool saturation, because a saturated pool turns every request into a wait and is the precursor to a timeout cascade.
Track the timeout rate per target, because a single target timing out repeatedly is usually a page that never settles rather than a pool that is too small.
Measure memory per browser across a recycle cycle, because a slow upward trend indicates a leak the recycle policy is masking rather than fixing.
Track the number of contexts created per browser, because a context leak shows up as a growing context count well before it shows up as memory pressure.
Troubleshooting
Screenshots time out on heavy pages. networkidle never settles; use a load event plus an explicit delay.
Browser instances leak. Contexts were never closed; always close the context in a finally block.
If captures time out for one site only, check the site’s loading behavior before the pool, because a page that keeps streaming resources will never reach an idle signal.
If memory climbs across requests, check the recycle policy, because browser instances that are never recycled retain memory even with clean contexts.
If every capture fails at once, check the browser instance and its system dependencies, because a missing shared library takes down the whole pool rather than one request.
If a capture returns a blank image, check the load signal before the pool, because a page captured before its content renders produces a valid but empty PNG.
Limitations and next steps
- Chromium only.
- No PDF output or video recording.
- Full-page capture of very long pages is memory heavy.
The service captures a static image of a given viewport and does not scroll or paginate a long page, so full-page and multi-page captures are out of scope.
There is no authentication to a target URL, so a screenshot of a private page would need a session passed in, which the example leaves out to keep the surface small.
Fonts and installed libraries affect rendering, so two deployments can produce slightly different images unless the browser image is pinned.
The service does not store screenshots; it returns them, so caching and retention are left to the caller.
The service renders a single viewport at a time and does not emulate device features beyond the viewport, so a device-specific capture would need additional emulation settings.
The service does not authenticate to the target or script interactions, so a page that requires a click to reveal content renders only its initial state.