ScreenshotNeo

BlogHow-to

Uploading Website Screenshots to S3-Compatible Storage

Capture a web page with Playwright, then upload its image safely to S3-compatible storage using a short-lived presigned URL and a browser-ready CORS policy.

By the ScreenshotNeo team29 September 202611 min read

Uploading Website Screenshots to S3-Compatible Storage

To upload a website screenshot to S3-compatible storage, capture the rendered page in a browser automation process such as Playwright, ask your trusted server for a presigned PUT URL, then send the screenshot bytes to that URL. For a browser-based upload, configure the bucket’s CORS policy to allow your site’s exact origin, the PUT method, and the headers used in the signed request. Keep permanent storage credentials on the server.

The workflow has two independent parts: browser rendering produces the image, and object storage accepts its bytes. A presigned URL gives a client temporary authorization for one storage operation without handing it the storage secret. CORS answers a different question: whether a browser page from your origin may make and inspect a cross-origin request. A valid signature does not bypass browser CORS rules. See the [Playwright Page API](https://playwright.dev/docs/api/class-page), [Cloudflare R2 presigned URL documentation](https://developers.cloudflare.com/r2/api/s3/presigned-urls/), and [R2 CORS documentation](https://developers.cloudflare.com/r2/buckets/cors/).

1. Capture the screenshot with Playwright

This Node.js example captures a full-page PNG, asks an application backend for a presigned URL, and uploads the image directly from the browser process. The backend endpoint shown here is application-specific: implement it to authenticate the caller, choose the object key, and sign a PUT operation for your storage provider. Do not expose storage access keys in frontend code.

Capture, authorization, and storage are separate steps in the upload flow.
Capture, authorization, and storage are separate steps in the upload flow.
import { chromium } from 'playwright';

const targetUrl = 'https://example.com';
const backendUrl = 'https://app.example.com/api/screenshot-upload';

const browser = await chromium.launch();
try {
  const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
  await page.goto(targetUrl, { waitUntil: 'networkidle', timeout: 60_000 });
  await page.screenshot({ path: 'page.png', fullPage: true, type: 'png' });

  // The authenticated application server chooses the object key and signs the PUT.
  const authorization = await fetch(backendUrl, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ contentType: 'image/png' })
  });
  if (!authorization.ok) throw new Error(`Could not get upload URL: ${authorization.status}`);
  const { uploadUrl, objectKey, headers = { 'Content-Type': 'image/png' } } =
    await authorization.json();

  const image = await (await import('node:fs/promises')).readFile('page.png');
  const uploaded = await fetch(uploadUrl, { method: 'PUT', headers, body: image });
  if (!uploaded.ok) throw new Error(`Storage upload failed: ${uploaded.status}`);
  console.log(`Uploaded object: ${objectKey}`);
} finally {
  await browser.close();
}

For a browser UI, the capture and PUT can run in browser JavaScript after your backend authorizes the user. Keep the same trust boundary: the browser receives only the narrowly scoped presigned URL, not bucket credentials. For a server-side capture service, the screenshot can be uploaded from the server process instead; browser CORS does not apply to server-to-server requests.

Choose when the page is ready

networkidle is a convenient example, but it is not a universal signal that the exact content you need has rendered. Pages with analytics polling, live updates, or persistent network activity may never become idle. Prefer waiting for a meaningful selector when the page has a known readiness condition:

await page.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout: 60_000 });
await page.locator('[data-report-ready="true"]').waitFor({ state: 'visible', timeout: 20_000 });

For a page without a reliable selector, wait for a small, deliberate delay after navigation and check the resulting capture. Font loading and lazy images can affect the final appearance. Full-page capture may trigger lazy-loaded content; inspect especially tall pages and sites whose content loads only after scrolling.

2. Create a presigned PUT URL on a trusted server

The upload authorization endpoint should validate the caller and any requested metadata, generate an unpredictable object key, and sign only the needed operation on that object. Return the URL, key, and any headers the client must send. Avoid letting an unauthenticated caller choose arbitrary bucket paths, overwrite valuable objects, or request unlimited upload authorizations.

Here is a provider-neutral outline of the endpoint contract. The signing implementation depends on the storage provider and SDK; consult that provider’s current S3-compatible documentation for endpoint, region, and signing details. This outline is not a drop-in signer:

POST /api/screenshot-upload

1. Authenticate the application user.
2. Validate contentType against supported image types.
3. Generate a unique key, for example: screenshots/{userId}/{randomId}.png
4. Sign a PUT to that exact bucket and key, with the intended Content-Type.
5. Set a short expiry that allows the client to finish the upload.
6. Return JSON: { uploadUrl, objectKey, headers: { "Content-Type": "image/png" } }

Cloudflare R2 documents presigned URLs for authorizing a specific S3 operation on an object. AWS also documents presigned uploads that let another party upload without holding AWS credentials. Treat the URL as a bearer credential: anyone who obtains it can perform its authorized operation until it expires. Do not log it, put it in a public page, or include it in analytics events. Keep its lifetime as short as the application can use reliably. ([R2 presigned URLs](https://developers.cloudflare.com/r2/api/s3/presigned-urls/), [AWS presigned upload](https://docs.aws.amazon.com/en_es/AmazonS3/latest/userguide/PresignedUrlUploadObject.html))

3. Upload the screenshot bytes

The browser upload is a PUT of the raw image bytes to the signed URL. Send the exact headers that were included in the signature. If the signer specified Content-Type: image/png, the client must send that same value. A mismatch can produce a signature validation error. R2’s JavaScript SDK example demonstrates matching the request’s content type to the signed content type.

const response = await fetch(uploadUrl, {
  method: 'PUT',
  headers: { 'Content-Type': 'image/png' },
  body: screenshotBlob
});
if (!response.ok) {
  throw new Error(`Upload failed with HTTP ${response.status}`);
}
const etag = response.headers.get('ETag'); // May be unavailable unless CORS exposes it.

Retain the object key returned by your backend as the durable application identifier. Do not treat a provider’s presigned URL as a permanent object address. Decide separately whether the uploaded object should be private, served through an authorized read URL, or deliberately made public. CORS configuration does not make private objects publicly readable.

4. Configure CORS for browser uploads

For a browser PUT, the storage bucket’s CORS policy should allow the exact origin of your application, the PUT method, and the request headers sent by the upload. Add headers for checksums or metadata only when the implementation uses them. If JavaScript needs to read the response’s ETag, configure the bucket to expose that response header. Follow the storage provider’s own CORS format and current guidance; “S3-compatible” providers can differ in details.

Setting What to allow Why
Origin Your app’s exact scheme, hostname, and port Limits which browser origins can make the cross-origin request
Method PUT for a presigned PUT upload Matches the upload operation
Request headers Content-Type and any actually used signed headers Allows the browser’s request, including its preflight when applicable
Exposed response headers ETag if client code must read it Allows JavaScript to inspect selected response metadata

Use the browser developer tools’ Network panel to inspect an OPTIONS preflight and the subsequent PUT. A command-line request succeeding does not prove that browser CORS is correct: command-line clients do not enforce browser CORS. Conversely, CORS is not authorization. The signature still controls the storage operation.

5. Pick capture settings that fit the image

Playwright’s page screenshot API supports viewport screenshots by default, full-page screenshots, clipped regions, and element screenshots. Choose the smallest output that meets the use case: full-page captures can be very tall, while a viewport or a specific element often produces a smaller and quicker upload.

  • Viewport or full page: use fullPage: true for the scrollable page, or omit it for the current viewport.
  • Specific content: use a locator’s screenshot method for a chart, card, or report section; use a clip rectangle when you need a precise region.
  • Format: PNG is lossless and suitable for text-heavy interfaces. JPEG can reduce size for photographic content. WebP is also supported by Playwright’s screenshot API; verify that the rest of your pipeline accepts the chosen type.
  • Scale: device scale can produce more pixels and larger files than CSS-pixel scale. Choose deliberately, especially for full-page captures.
  • Animations: disabling animations can make repeated screenshots more consistent. It changes the captured state, so use it only when that is appropriate.
  • Privacy: mask sensitive page regions when screenshots could expose personal information, credentials, or internal data. Playwright documents locator masking options in its [Page API](https://playwright.dev/docs/api/class-page).

Example of a clipped capture:

await page.screenshot({
  path: 'region.png',
  clip: { x: 0, y: 0, width: 900, height: 600 },
  type: 'png',
  animations: 'disabled'
});

6. Choose direct upload or an application-server proxy

With a presigned URL, the browser sends screenshot bytes directly to object storage. That avoids routing those bytes through your application server and is the usual fit for a browser client. A proxy upload sends the bytes through your server first, which can centralize inspection and application logic but adds server traffic and load. Server-side browser automation can also upload directly from the capture worker, without browser CORS.

For ordinary website screenshots, a single PUT is generally the simplest option. Cloudflare R2 documents single uploads up to 5 GiB and multipart uploads up to 5 TiB across as many as 10,000 parts; these are R2-specific documented limits, not universal S3-compatible guarantees. Multipart upload can help with very large objects, parallelism, or resumability, but it adds steps and provider-specific behavior. Measure actual image sizes and check the target provider’s current limits before relying on a limit. ([R2 upload objects](https://developers.cloudflare.com/r2/objects/upload-objects/))

7. Complete runnable alternatives for upload clients

The capture code above is Node.js. These examples show the storage-upload portion when a trusted application server has already provided a presigned URL. Do not paste real presigned URLs into shared logs or source control.

A clean capture can remove consent banners and overlays before the image is produced.
A clean capture can remove consent banners and overlays before the image is produced.

cURL

curl -X PUT \
  -H 'Content-Type: image/png' \
  --upload-file page.png \
  'PRESIGNED_PUT_URL'

Use the exact headers returned by the signing service. cURL is useful for isolating storage authorization from browser CORS: if it succeeds but a browser fails, inspect the bucket’s origin, methods, and allowed headers.

Python

from pathlib import Path
import requests

upload_url = 'PRESIGNED_PUT_URL'
image_bytes = Path('page.png').read_bytes()
response = requests.put(
    upload_url,
    data=image_bytes,
    headers={'Content-Type': 'image/png'},
    timeout=60,
)
response.raise_for_status()
print('Upload completed')

This assumes the URL was created for a PUT with the same content type. Server-side Python requests are not subject to browser CORS enforcement, but they still need a valid, unexpired signature and matching signed headers.

Node.js

import { readFile } from 'node:fs/promises';

const uploadUrl = 'PRESIGNED_PUT_URL';
const bytes = await readFile('page.png');
const response = await fetch(uploadUrl, {
  method: 'PUT',
  headers: { 'Content-Type': 'image/png' },
  body: bytes
});
if (!response.ok) {
  throw new Error(`Upload failed: ${response.status} ${response.statusText}`);
}
console.log('Upload completed');

Or skip the browser setup

Capture and store a screenshot with ScreenshotNeo, a website screenshot API and MCP server. One GET request returns an image or PDF. For more options, 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

Cookie banners, popups, and chat widgets are removed 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. Download the result and upload it to your bucket using the presigned PUT flow above.

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

Troubleshooting

Symptom Likely cause What to check or change
Browser reports a CORS error Origin, method, or request headers are missing from the bucket rule; the preflight failed Inspect OPTIONS in developer tools. Allow the exact app origin, PUT, and sent headers in the provider’s bucket CORS settings.
cURL works but browser upload fails cURL does not enforce browser CORS Fix the bucket’s CORS rule; do not change a valid signature just to silence a browser message.
Signature mismatch or HTTP 403 URL expired, wrong method, altered URL, or signed header mismatch Request a fresh URL; use PUT and send the exact signed content type and other required headers.
Browser cannot read the error body Some error responses, including expired R2 presigned URL responses, may lack CORS headers Renew the URL before expiry and handle renewal in the application. Diagnose the underlying response in server logs or provider tools without logging the full bearer URL.
Upload succeeds but app cannot read ETag The response header is not exposed to browser JavaScript Add ETag to exposed headers in the bucket CORS policy if the app needs it.
Screenshot misses content Capture ran before the content was ready, or lazy content did not load Wait for a meaningful selector or site-specific readiness signal; inspect full-page behavior and image/font loading.
Object is larger than expected Full-page height, device scale, or lossless format increased output bytes Capture only needed regions, choose an appropriate format and scale, and check provider size limits.
Storage accepts the PUT but object is inaccessible Upload authorization and read access are separate Check the object’s intended private/public policy and use the appropriate authorized read path.

Performance, reliability, and cost

Capture time is usually affected by page rendering and readiness waits; upload time is affected by image size and network conditions. Reduce work by capturing a viewport or element when that is sufficient, selecting a suitable format and scale, and avoiding broad waits that never settle. A retry should usually obtain a fresh presigned URL if the old one expired. Retry transient network failures with a bounded policy and avoid blindly repeating a request after an ambiguous timeout if overwriting the same key would be harmful. Unique object keys make retry behavior easier to reason about.

Presigned URLs are temporary authorization, so an expiry that is too short can fail on a slow connection; one that is unnecessarily long extends the period in which a leaked URL can be used. R2 documents expiries from one second to seven days, but other providers may differ. Choose a short practical lifetime and verify the provider’s current rules.([R2 presigned URLs](https://developers.cloudflare.com/r2/api/s3/presigned-urls/))

There is no universal cost estimate for this flow: storage, requests, data transfer, browser compute, and screenshot service charges depend on the selected providers and usage. Compare the capture environment as well as storage. Running Playwright gives control over browser setup and rendering, while a hosted capture option can reduce browser operations work. If you use ScreenshotNeo, its stated plans are Free for 1,000 shots/month, Starter $5 for 3,000, Growth $15 for 15,000, Pro $39 for 60,000, Scale $99 for 250,000, and Business $249 for 1,000,000; yearly billing gives two months free, and every feature is on every plan. Storage charges remain separate.

Frequently asked questions

Can I upload a screenshot directly from frontend JavaScript?

Yes, using a presigned URL returned by your trusted backend. Do not put permanent bucket credentials in the frontend. The bucket must also permit the browser request through CORS.

Does a presigned URL make an object public?

No. It authorizes the signed operation for its holder until expiry. Whether the resulting object can be read publicly depends on your bucket and object access policy.

Does every S3-compatible provider work the same way?

No. Compatibility does not guarantee identical endpoints, region values, signing behavior, checksums, size limits, or CORS configuration. Confirm details against the chosen provider’s current documentation.

Should I use a single PUT or multipart upload?

For typical screenshot files, begin with a single PUT. Consider multipart when object size or resumability needs justify the added implementation, and use the provider’s documented limits.