ScreenshotNeo

BlogHow-to

Upload Website Screenshots to S3-Compatible Storage

Capture a website screenshot with Playwright, then upload it safely to S3 or compatible storage using a backend-signed URL.

By the ScreenshotNeo team4 October 202611 min read

To upload a website screenshot to S3-compatible storage, capture it with a browser automation tool such as Playwright, then upload the image to an object key using either your backend or a short-lived presigned URL. For a browser client, the safer common pattern is: the backend authenticates the user and signs a constrained upload request; the browser sends the image directly to storage. The browser never receives long-lived storage credentials.

This guide uses Playwright and AWS S3 terminology. Other S3-compatible providers can differ in endpoint, region, signing, addressing, CORS, object-size limits, and SDK support, so check the chosen provider’s official documentation before adopting configuration values. AWS documentation describes presigned URLs as bearer tokens, limited by the signer’s permissions and subject to expiry.

1. Choose where screenshot generation and upload happen

There are two separate decisions: where the page is rendered and where the image is uploaded. For a reliable automated capture, run Playwright in a trusted server-side worker. For a user-triggered capture in a browser, capture there and upload through a backend-issued presigned URL. A browser-only app should not contain an AWS access key or another long-lived storage secret.

Pattern Credentials Data path Useful when
Server captures and uploads Server or worker holds scoped storage credentials Page → capture worker → storage Automated jobs, private pages, centralized capture policy
Browser captures, backend proxies upload Backend holds credentials Browser → app backend → storage Small files or when the backend must inspect or transform every image
Browser captures, backend signs direct upload Backend holds credentials; browser gets a temporary URL Browser → storage Direct uploads without routing image bytes through the application server

For browser uploads, direct transfer reduces application-server bandwidth, but requires correct CORS and signing configuration. A server-mediated upload is simpler to reason about when the server must validate, transform, or inspect the file, but consumes server bandwidth and processing. AWS also documents browser-based POST uploads; POST policies are useful when you need policy conditions on the form upload. A presigned PUT is a straightforward fit when the client already has the image bytes and needs to write one known object key.

2. Capture the screenshot with Playwright

Install Playwright and its Chromium browser in the environment that will perform the capture:

npm install playwright
npx playwright install chromium

The following runnable Node.js example captures a full-page PNG to a buffer. It then uploads the bytes to a server-issued presigned PUT URL. Save it as capture-upload.mjs; set UPLOAD_URL to the URL your backend returns. Keep the presigned URL secret and use it before it expires.

import { chromium } from 'playwright';

const targetUrl = process.env.TARGET_URL ?? 'https://example.com';
const uploadUrl = process.env.UPLOAD_URL;
if (!uploadUrl) throw new Error('Set UPLOAD_URL to a backend-issued presigned PUT URL');

const browser = await chromium.launch({ headless: true });
try {
  const page = await browser.newPage({
    viewport: { width: 1440, height: 900 },
    deviceScaleFactor: 1
  });
  const response = await page.goto(targetUrl, {
    waitUntil: 'load',
    timeout: 30_000
  });
  if (!response || !response.ok()) {
    throw new Error(`Navigation failed: ${response?.status() ?? 'no response'}`);
  }

  const png = await page.screenshot({ fullPage: true, type: 'png' });
  const upload = await fetch(uploadUrl, {
    method: 'PUT',
    headers: { 'Content-Type': 'image/png' },
    body: png
  });
  if (!upload.ok) {
    throw new Error(`Storage upload failed: ${upload.status} ${await upload.text()}`);
  }
  console.log('Screenshot uploaded');
} finally {
  await browser.close();
}

Playwright supports writing screenshots to a file or returning screenshot bytes, full-page capture, and element capture. Its screenshot API supports PNG, JPEG, and WebP as well as scale controls. See the Playwright screenshot guide and Page screenshot options for current API details.

Choose viewport, full page, or an element

  • Viewport: use await page.screenshot({ type: 'png' }) for the current visible viewport. This is usually smaller and faster than full-page output.
  • Full page: use fullPage: true to capture the full scrollable page. Very long pages may create large images and use substantial memory. Lazy-loaded content may not appear unless you scroll it into view or otherwise wait for it to load.
  • Element: locate a stable selector and call await page.locator('#report').screenshot({ type: 'png' }). This avoids capturing unrelated page content; the selector must match a visible element.

Image type and scale

Choice Tradeoff Typical fit
PNG Lossless; often larger Text, UI details, visual regression
JPEG Lossy; quality can be set Photographic pages where smaller files matter
WebP Compact modern image format Storage or delivery where consumers support it
Scale CSS One output pixel per CSS pixel Smaller screenshots and consistent CSS-sized output
Scale device Uses device scale factor; can produce more pixels and larger files Sharper high-density output

For example, set deviceScaleFactor: 2 in the browser context and use scale: 'device' when appropriate. Higher pixel dimensions increase memory use, transfer size, and storage use. Fix viewport, device scale, fonts, locale, and page state when screenshots are used for visual comparison.

3. Issue a constrained presigned upload URL

The signing service should authenticate and authorize the requester, choose the bucket and object key itself, and sign only the intended operation with a short expiry. Do not let an untrusted client choose an arbitrary bucket or key. Use a unique key for each capture unless replacement is deliberate. On S3, writing to an existing key replaces that object.

Here is a minimal AWS SDK for JavaScript v3 backend example. Install the SDK packages with npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner. The AWS credentials come from the backend’s normal credential provider, such as its assigned role; they must not be sent to the browser. This code creates a unique PNG key and returns a URL for a PUT with a signed content type.

import { randomUUID } from 'node:crypto';
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';

const bucket = process.env.S3_BUCKET;
if (!bucket) throw new Error('Set S3_BUCKET');

const s3 = new S3Client({
  region: process.env.S3_REGION ?? 'us-east-1',
  ...(process.env.S3_ENDPOINT ? { endpoint: process.env.S3_ENDPOINT } : {})
});

export async function createScreenshotUpload() {
  // In a real route, authenticate the caller and enforce per-user authorization here.
  const key = `screenshots/${randomUUID()}.png`;
  const command = new PutObjectCommand({
    Bucket: bucket,
    Key: key,
    ContentType: 'image/png'
  });
  const uploadUrl = await getSignedUrl(s3, command, { expiresIn: 300 });
  return { uploadUrl, key, contentType: 'image/png' };
}

Expose this function through an authenticated application route that returns JSON to the authorized caller. Add rate limits and enforce which user may create captures. Scope the signer’s permissions to the required bucket and key prefix where your provider supports that policy. A signed URL inherits the signing principal’s permissions; it is not a way to grant more access than that principal has.

For S3-compatible services, configure the SDK with the provider’s documented endpoint and region. AWS SDK for JavaScript v3 supports a custom endpoint string, but this does not establish that every compatible service accepts identical addressing or signing behavior. Check the provider’s official guidance for endpoint, signing version, path-style versus virtual-hosted addressing, and SDK compatibility.

4. Configure browser CORS for direct upload

The storage bucket must allow the browser page’s origin to make the intended request. CORS is separate from authorization: CORS does not make a public bucket private or make an invalid signature valid. Configure the narrowest allowed origin, method (usually PUT for the example), and request headers (such as Content-Type) that your client needs. The exact configuration syntax and supported rules differ by provider; use that provider’s official documentation rather than copying AWS-specific settings blindly.

If the browser reports a CORS error, verify the allowed origin exactly matches the app’s scheme, host, and port, and that the preflight response permits the method and headers. A request that works from a server-side script can still fail in a browser because server-side HTTP clients do not enforce browser CORS.

5. Alternative: upload through the backend

If the backend needs to inspect the image or direct browser uploads are not suitable, send the screenshot to an authenticated backend endpoint and have that server upload it. The following Python example uses Playwright’s Python API to capture a page, then uploads it through an application endpoint. The application endpoint should validate user authorization, content size and type, choose the object key, and use credentials held on the server.

from playwright.sync_api import sync_playwright
import requests

page_url = 'https://example.com'
upload_endpoint = 'https://app.example.com/api/screenshots'

with sync_playwright() as p:
    browser = p.chromium.launch(headless=True)
    page = browser.new_page(viewport={"width": 1440, "height": 900})
    response = page.goto(page_url, wait_until='load', timeout=30_000)
    if response is None or not response.ok:
        raise RuntimeError(f'Navigation failed: {response.status if response else "no response"}')
    image_bytes = page.screenshot(full_page=True, type='png')
    browser.close()

with open('screenshot.png', 'wb') as f:
    f.write(image_bytes)

with open('screenshot.png', 'rb') as f:
    result = requests.post(
        upload_endpoint,
        files={'file': ('screenshot.png', f, 'image/png')},
        timeout=90,
    )
result.raise_for_status()
print(result.text)

The endpoint above is an application endpoint you implement; it is not an AWS API URL. This separation lets the app enforce ownership and naming policy without exposing cloud credentials.

6. cURL and Python clients for a presigned PUT

Once the backend has returned uploadUrl and the expected content type, a file can be uploaded without AWS credentials. The URL is a secret bearer token until it expires. Do not put it in logs, analytics, source control, or public pages.

curl --fail-with-body -X PUT \
  -H 'Content-Type: image/png' \
  --upload-file screenshot.png \
  "$UPLOAD_URL"

Python’s requests package can send the same request:

import os
import requests

with open('screenshot.png', 'rb') as image:
    response = requests.put(
        os.environ['UPLOAD_URL'],
        data=image,
        headers={'Content-Type': 'image/png'},
        timeout=90,
    )
response.raise_for_status()
print('Screenshot uploaded')

If the signing request included a content type, the upload request must use the same value. Do not add or change signed headers unless the provider’s signing process allows them.

7. Pick an object naming and retention policy

  • Unique or versioned keys: use an unpredictable unique identifier or include a version/timestamp. This avoids accidental replacement and is appropriate for history, audit records, and regression runs.
  • Stable keys: use a predictable path such as a report’s current screenshot only when replacement is intended. On S3, a PUT to an existing key replaces its current object; versioning behavior depends on bucket configuration.
  • Metadata and content type: set the correct type such as image/png, and sign it consistently. Set cache metadata only when your delivery and replacement policy is clear.
  • Access and cleanup: keep buckets private unless public delivery is an explicit requirement. Define lifecycle/retention rules for repeated captures according to the provider’s capabilities and your application’s needs.

8. Reliability, performance, and cost

Capture and upload failures are independent, so report them separately. Record the target URL, capture status, chosen object key, storage response code, and a correlation identifier, but redact presigned URLs and credentials. Retry transient navigation or network failures with a bounded retry policy and backoff; do not retry authorization failures or invalid signatures without correcting the request.

Full-page captures and device-scale output can consume more memory and create larger transfers than viewport captures. Prefer the smallest viewport, format, and scale that meet the use case. Reuse browser processes or contexts carefully for throughput, while isolating cookies and state between unrelated users. Set navigation and upload timeouts; close pages and browsers in cleanup paths. If captures must survive worker restarts, persist job state and make retries idempotent with a unique job identifier or deliberate overwrite rule.

Costs depend on your compute, storage provider, stored bytes, and any request or delivery charges. No universal price or throughput figure applies across compatible providers. Estimate from observed image sizes and capture volume, and check the selected provider’s current pricing and limits. Avoid retaining every retry artifact unless you need it.

9. Troubleshooting

Symptom Likely cause Fix
SignatureDoesNotMatch or equivalent Signed headers differ, endpoint/region/signing settings are wrong, or URL was altered Use the exact signed URL and headers; verify provider endpoint, region, addressing, and signing requirements.
Expired token or access denied URL expired, signer lacks permission, or temporary credentials expired earlier Request a fresh URL; check signer permissions and the lifetime of its credentials.
Browser CORS failure Origin, PUT method, or request headers are not allowed Adjust provider bucket CORS for the exact app origin and upload request; inspect the browser preflight response.
HTTP 403 despite a fresh URL Wrong bucket/key, denied policy, incompatible signing configuration, or signed content type mismatch Compare signer inputs and client request exactly; check provider policy and SDK configuration.
HTTP 404 or host lookup failure Incorrect custom endpoint, bucket addressing style, or region Use the endpoint and addressing pattern documented by the provider and verify DNS/network access.
Uploaded image is empty or unreadable Capture failed or code uploaded the wrong buffer/file Check navigation response and screenshot byte length; upload the screenshot bytes and set the matching content type.
Screenshot misses images or lower-page content Lazy loading, animations, fonts, or client rendering had not settled Wait for a meaningful selector or page-specific readiness signal; scroll lazy content into view when needed; use fixed capture conditions.
PUT replaced a previous screenshot The same object key was reused Generate a unique/versioned key or explicitly configure the intended replacement and version-retention behavior.
Request too large or slow Full-page or high-density capture generated a large image, or provider limits were reached Reduce page area or scale, choose an appropriate format, or use a provider-supported multipart/resumable design after checking its limits.

10. A simpler capture option

If you want the screenshot without managing browser installation, capture timing, or browser cleanup, ScreenshotNeo can generate the screenshot; your application can then upload its returned image bytes to your storage using the same presigned URL pattern. See the ScreenshotNeo API documentation for request options.

cURL:

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp

Python:

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)

Node.js:

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

ScreenshotNeo removes cookie banners, popups, and chat widgets before the shot. Bot checks, blank pages, and failed loads are never billed. Its MCP server lets AI agents take screenshots. The free plan includes 1,000 screenshots a month with no card; paid plans start at $5 for 3,000. Your storage upload still uses your own provider configuration and credentials.

Sign up free for 1,000 screenshots a month with no card.

FAQ

Can I upload a screenshot to any S3-compatible provider with the same code?

The workflow is portable, but client configuration and policies may not be. Verify the chosen provider’s endpoint, region, signing, CORS, addressing, limits, and SDK compatibility.

Does a presigned URL make the object public?

No. It authorizes the specific signed operation for whoever holds the URL until it expires; public read access is a separate bucket or delivery policy.

Should screenshots be PNG or JPEG?

Use PNG when exact text and UI edges matter; consider JPEG for photographic content where lossy compression is acceptable. Confirm consumer support before choosing WebP.

Can the screenshot be generated in the browser and uploaded directly?

Yes, if the capture is available in the browser and your backend issues a constrained upload URL. Configure CORS and never expose long-lived storage credentials to that page.

Sources