How to Build a Playwright Screenshot API with FastAPI
Build a FastAPI endpoint that captures web pages with Playwright and returns PNG, JPEG, or WebP bytes. Learn lifecycle, validation, deployment, and security essentials.
To build a Playwright screenshot API with FastAPI, define a validated request model, reuse a Playwright browser managed by FastAPI lifespan, create a fresh browser context for each request, capture screenshot bytes, and return them in a FastAPI Response with the correct image media type. The example below supports viewport or full-page PNG, JPEG, and WebP captures.
1. Install the dependencies
Save this as requirements.txt:
fastapi
uvicorn[standard]
playwright
pydantic
Install Python packages and Chromium:
python -m pip install -r requirements.txt
python -m playwright install chromium
Use a virtual environment for local development. In deployment, pin Python dependencies and the browser image or browser version together; a package/browser mismatch can keep Playwright from finding the browser executable.
2. Create the FastAPI screenshot endpoint
Save the following as main.py. The dimension limits below are example product decisions, not Playwright or FastAPI defaults. Adjust them to the memory and workload limits of your service.
from contextlib import asynccontextmanager
from typing import Literal
from urllib.parse import urlparse
from fastapi import FastAPI, HTTPException
from fastapi.responses import Response
from pydantic import BaseModel, Field, field_validator
from playwright.async_api import async_playwright, TimeoutError as PlaywrightTimeoutError
ImageType = Literal["png", "jpeg", "webp"]
MEDIA_TYPES = {
"png": "image/png",
"jpeg": "image/jpeg",
"webp": "image/webp",
}
class ScreenshotRequest(BaseModel):
url: str = Field(min_length=1, max_length=2048)
width: int = Field(default=1280, ge=320, le=2560)
height: int = Field(default=900, ge=240, le=2560)
full_page: bool = False
image_type: ImageType = "png"
quality: int | None = Field(default=None, ge=1, le=100)
@field_validator("url")
@classmethod
def require_http_url(cls, value: str) -> str:
parsed = urlparse(value)
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
raise ValueError("url must be an absolute http or https URL")
return value
@field_validator("quality")
@classmethod
def quality_only_for_lossy_formats(cls, value, info):
image_type = info.data.get("image_type")
if value is not None and image_type == "png":
raise ValueError("quality is supported here only for jpeg or webp")
return value
@asynccontextmanager
async def lifespan(app: FastAPI):
playwright = await async_playwright().start()
browser = await playwright.chromium.launch()
app.state.playwright = playwright
app.state.browser = browser
try:
yield
finally:
await browser.close()
await playwright.stop()
app = FastAPI(lifespan=lifespan)
@app.post("/screenshot")
async def screenshot(request: ScreenshotRequest):
browser = app.state.browser
context = await browser.new_context(
viewport={"width": request.width, "height": request.height}
)
try:
page = await context.new_page()
try:
await page.goto(request.url, wait_until="domcontentloaded", timeout=15_000)
except PlaywrightTimeoutError:
raise HTTPException(status_code=504, detail="Navigation timed out")
options = {"type": request.image_type, "full_page": request.full_page}
if request.quality is not None:
options["quality"] = request.quality
image_bytes = await page.screenshot(**options)
return Response(
content=image_bytes,
media_type=MEDIA_TYPES[request.image_type],
headers={"Cache-Control": "no-store"},
)
except PlaywrightTimeoutError:
raise HTTPException(status_code=504, detail="Screenshot capture timed out")
except HTTPException:
raise
except Exception:
# Log the exception server-side with a request ID; do not expose browser details.
raise HTTPException(status_code=502, detail="Page could not be captured")
finally:
await context.close()
Run it locally:
uvicorn main:app --host 127.0.0.1 --port 8000
FastAPI’s request model validates the JSON fields before the handler runs. The URL validator above checks syntax only; it does not make fetching arbitrary URLs safe for a public service. The handler returns the screenshot bytes directly with the media type instead of asking FastAPI to serialize them as JSON. See the FastAPI direct response documentation.
3. Call the endpoint
cURL
curl -sS http://127.0.0.1:8000/screenshot \
-H 'Content-Type: application/json' \
-d '{"url":"https://example.com","width":1280,"height":900,"full_page":true,"image_type":"png"}' \
-o screenshot.png
Python
import requests
response = requests.post(
"http://127.0.0.1:8000/screenshot",
json={
"url": "https://example.com",
"width": 1280,
"height": 900,
"full_page": True,
"image_type": "png",
},
timeout=30,
)
response.raise_for_status()
with open("screenshot.png", "wb") as image_file:
image_file.write(response.content)
Node.js
const response = await fetch('http://127.0.0.1:8000/screenshot', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
url: 'https://example.com',
width: 1280,
height: 900,
full_page: true,
image_type: 'png'
})
});
if (!response.ok) {
throw new Error(`Screenshot API returned ${response.status}: ${await response.text()}`);
}
const image = Buffer.from(await response.arrayBuffer());
await import('node:fs/promises').then(fs => fs.writeFile('screenshot.png', image));
The response body is binary image data. Clients should check the status before saving it and should use the requested output extension or inspect the Content-Type header.
4. Choose capture behavior
| Need | Implementation | Trade-off |
|---|---|---|
| Visible viewport | page.screenshot() with viewport dimensions |
Predictable output dimensions and usually less work. |
| Entire document | page.screenshot(full_page=True) |
Can create very tall images and consume substantial memory. |
| One component | await page.locator(".report").screenshot(type="png") |
Useful for a chart or card; selector must resolve to a visible element. |
| PNG | image_type="png" |
Lossless; quality is not applicable. |
| JPEG or WebP | image_type="jpeg" or "webp", optional quality from 1–100 |
Smaller output may be possible, with lossy encoding. |
Playwright’s Python API returns screenshot bytes, supports full-page screenshots, and can capture a locator. See Playwright screenshots.
Wait for the page you actually need
domcontentloaded is a bounded starting point, but it does not mean that client-rendered content, fonts, or images are ready. For a known application, wait for a meaningful selector:
await page.goto(request.url, wait_until="domcontentloaded", timeout=15_000)
await page.locator("main .report-ready").wait_for(state="visible", timeout=5_000)
If you control the target page, a readiness marker is often more dependable than waiting for all network activity to stop. Some sites keep analytics, polling, or streaming connections open, so a network-idle condition can wait indefinitely until the timeout. For lazy-loaded content, scrolling or waiting for specific images may be necessary before a full-page shot; validate the behavior for the pages you support.
5. Understand lifecycle, isolation, and binary responses
FastAPI lifespan is intended for application-wide resources that need startup and cleanup. The example launches one browser per application process and closes it at shutdown. Each request gets its own browser context, which separates cookies and page state; the finally block closes that context after success or failure. Read FastAPI lifespan events.
Returning a Response subclass passes content through directly. FastAPI does not apply response-model conversion or validate the binary payload, so the handler must set the correct media type and headers. The example sends Cache-Control: no-store because captures can contain private page data; choose a different cache policy only if the service’s privacy and freshness requirements support it.
Starting a browser on every request is simpler to reason about but adds launch work and can increase latency and resource churn. A shared browser with per-request contexts avoids repeated launches, but requires limits on concurrent contexts and careful shutdown. There are no universal pool sizes: measure with your page mix and available memory before setting concurrency.
6. Add safeguards before exposing it publicly
A URL-taking screenshot endpoint is a server-side network client. An attacker may try to make it fetch internal services, cloud metadata endpoints, or local files. The example is appropriate as a local vertical slice, not a complete public-service security policy.
- Require authentication and apply per-user rate limits.
- Allow only HTTP and HTTPS, and consider an explicit hostname allowlist when the use case permits it.
- Block loopback, private, link-local, and other internal IP ranges. Account for DNS rebinding, multiple resolved addresses, IPv6, and redirects; a hostname-only check is insufficient.
- Enforce outbound network restrictions at the container or network layer as a second boundary.
- Bound navigation and capture time, viewport dimensions, full-page output, concurrent browser contexts, and request frequency.
- Run the browser with an appropriate isolation profile and a dedicated non-root user when navigating untrusted sites.
- Avoid returning raw browser exceptions or target response bodies to callers. Log a request ID and sanitized diagnostic details server-side.
These are service-design controls; the cited browser documentation highlights special precautions for untrusted sites but does not define a complete SSRF policy. Threat-model the destinations and deployment network for your environment.
7. Deploy with a compatible browser runtime
For a container, install the Playwright package, browser binary, and operating-system dependencies in the image, or use a versioned Playwright image. Keep its version aligned with the Playwright Python package; mismatches can prevent browser executable discovery. The Playwright Docker guide recommends an init process to help avoid PID 1 zombie-process issues. For Chromium it recommends --ipc=host, since insufficient shared memory can cause Chromium to run out of memory and crash.
For untrusted browsing, follow the Docker guidance on a dedicated user and seccomp profile. Do not use disabling browser isolation as a generic production shortcut. Validate fonts, browser dependencies, available shared memory, and shutdown behavior on the actual deployment target.
Run multiple server workers only with awareness that each process will initialize its own browser. That can multiply memory use. Start with measured workload limits, then select worker and concurrency settings that fit the host.
8. Performance, reliability, and cost
- Browser reuse: Reusing a browser process avoids launching Chromium for every request; contexts still have creation and page-render costs. No benchmark is implied here, so profile your real destinations.
- Capture size: Full-page output, high dimensions, and image encoding affect memory, response bandwidth, and time. Set explicit upper bounds and reject excessive requests.
- Readiness: Short waits can produce incomplete pages; broad waits can waste capacity or time out. Prefer application-specific readiness conditions with a hard deadline.
- Concurrency: Browser pages compete for CPU and memory. Use a semaphore or bounded worker queue and return a clear overload response instead of allowing unbounded work.
- Retries: Retrying a deterministic 4xx request will not fix it. If retrying transient navigation failures, cap attempts and total elapsed time so retries do not amplify load.
- Direct response versus jobs: Direct image bytes work well for bounded synchronous captures. For slow, large, or bulk work, consider a job ID and artifact storage, with retention and access controls defined for your data.
- Cost: The main service costs are browser compute, memory, network egress, and any artifact storage. Estimate from observed capture time and resource use; no universal per-shot cost can be inferred without a deployment and workload.
Or skip the browser setup
If you need a screenshot endpoint without maintaining Playwright browsers and their container lifecycle, ScreenshotNeo provides a website screenshot API and MCP server. One GET request returns PNG, JPEG, WebP, or PDF. See the ScreenshotNeo API documentation.
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
- Cookie banners are accepted and removed before capture; 60+ known consent platforms, newsletter popups, and chat widgets are removed, and each cleanup step can be turned off.
- Bot checks, blank pages, timeouts, failed loads, and cache hits cost nothing; response headers report the page verdict and whether the request was billed.
- An MCP server provides
take_screenshot,get_page_info, andcapture_pdffor Claude, Cursor, and other MCP clients. - The free plan includes 1,000 screenshots per month with no card. Paid plans start at $5 for 3,000 screenshots; every feature is available on every plan.
Sign up for 1,000 free screenshots a month, with no card required.
9. Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Browser executable not found | Browser binaries were not installed, or package and browser versions do not align. | Install Chromium in the runtime image and pin compatible Playwright package and image versions. |
| Chromium crashes in a container | Insufficient shared memory or process-management setup. | Review Playwright Docker guidance for --ipc=host and an init process; check container memory and shared memory. |
| 504 navigation timeout | The target is slow, unreachable, or waiting on a readiness condition that never occurs. | Check target reachability, use a relevant readiness condition, and keep a finite timeout. Do not increase timeouts without resource limits. |
| Image is blank or incomplete | The screenshot was taken before client rendering or lazy content completed. | Wait for a page-specific selector or image state; use a controlled scroll strategy for lazy loading. |
| Full-page screenshot is very tall or memory-heavy | The document has a large height or unbounded content. | Limit full-page capture, dimensions, and concurrency; capture a locator or viewport if that meets the need. |
| Response looks like corrupt image data | The client saved a JSON error response as an image, or used the wrong file extension. | Check HTTP status and Content-Type before writing the body; inspect the error response separately. |
| 422 response | Request JSON failed model validation or used an unsupported option. | Send a valid absolute HTTP(S) URL, bounded dimensions, a supported image type, and quality only for JPEG/WebP. |
| 502 capture error | Navigation or rendering failed for another browser error. | Check sanitized server logs using the request ID; confirm the destination is reachable and permitted. |
10. FAQ
Can I return a screenshot from a FastAPI route without saving a file?
Yes. Playwright returns screenshot bytes, and FastAPI can return those bytes directly in a Response. A temporary file is unnecessary for a synchronous image response.
Should each request launch a new browser?
For a small isolated tool, that can be a simple starting point. For a service, a shared browser with a fresh context per request avoids repeated browser launches. Compare the operational and isolation needs of your workload.
Does full_page=True include every lazy-loaded image?
It captures the document beyond the viewport, but lazy content may require scrolling or a page-specific readiness step before capture. Confirm the target page’s behavior.
Can this endpoint safely accept any URL?
No. Syntax validation is not destination authorization. Public deployments need SSRF defenses, outbound network controls, authentication, and resource limits.
How do I return a PDF instead?
Use Playwright’s PDF generation in a separately designed route and return application/pdf. PDF output has different page sizing and printing behavior, so it should have its own request options and limits.


