ScreenshotNeo

BlogHow-to

How to capture a Cloudinary website screenshot with Puppeteer

Capture a page or element with Puppeteer, then upload the screenshot to Cloudinary using a local file or an in-memory stream.

By the ScreenshotNeo team4 October 202610 min read

Use Puppeteer to open the website and capture it with page.screenshot(), then upload the resulting file with Cloudinary’s Node.js SDK. For a component instead of a whole page, use an element handle’s screenshot() method. You can save the image locally and upload its path, or keep the bytes in memory and send them through Cloudinary’s upload_stream(). Keep Cloudinary’s API secret on the server. Puppeteer screenshot guide · Cloudinary Node.js upload guide.

1. Set up the Node.js project

This example uses the Puppeteer package and Cloudinary’s Node.js SDK. Install them in a new or existing Node.js project:

npm install puppeteer cloudinary

Set credentials in the server environment. The Cloudinary SDK recognizes these variables:

export CLOUDINARY_CLOUD_NAME="YOUR_CLOUD_NAME"
export CLOUDINARY_API_KEY="YOUR_API_KEY"
export CLOUDINARY_API_SECRET="YOUR_API_SECRET"

Do not put the API secret in browser JavaScript, a public repository, or client-side build variables. Cloudinary’s upload documentation explains authenticated and unsigned upload options; this guide uses a server-side authenticated upload. Cloudinary upload security.

2. Capture a page, save it, and upload it

The following runnable script opens a URL, takes a full-page PNG, uploads the local file, prints the secure delivery URL and asset metadata, and closes Chromium even if navigation or upload fails. Save it as capture-and-upload.mjs and run it with node capture-and-upload.mjs https://example.com.

import puppeteer from 'puppeteer';
import { v2 as cloudinary } from 'cloudinary';
import { resolve } from 'node:path';

const targetUrl = process.argv[2];
if (!targetUrl) {
  throw new Error('Usage: node capture-and-upload.mjs https://example.com');
}

cloudinary.config({
  cloud_name: process.env.CLOUDINARY_CLOUD_NAME,
  api_key: process.env.CLOUDINARY_API_KEY,
  api_secret: process.env.CLOUDINARY_API_SECRET,
});

if (!process.env.CLOUDINARY_CLOUD_NAME ||
    !process.env.CLOUDINARY_API_KEY ||
    !process.env.CLOUDINARY_API_SECRET) {
  throw new Error('Set CLOUDINARY_CLOUD_NAME, CLOUDINARY_API_KEY, and CLOUDINARY_API_SECRET.');
}

const outputPath = resolve('website-shot.png');
let browser;

try {
  browser = await puppeteer.launch();
  const page = await browser.newPage();
  await page.setViewport({ width: 1440, height: 900, deviceScaleFactor: 1 });

  const response = await page.goto(targetUrl, {
    waitUntil: 'networkidle2',
    timeout: 60_000,
  });
  if (response && !response.ok()) {
    throw new Error(`Navigation returned HTTP ${response.status()}`);
  }

  await page.screenshot({ path: outputPath, type: 'png', fullPage: true });

  const uploaded = await cloudinary.uploader.upload(outputPath, {
    resource_type: 'image',
  });
  console.log(JSON.stringify({
    secure_url: uploaded.secure_url,
    public_id: uploaded.public_id,
    format: uploaded.format,
    width: uploaded.width,
    height: uploaded.height,
    bytes: uploaded.bytes,
  }, null, 2));
} finally {
  if (browser) await browser.close();
}

The upload result includes a secure delivery URL and asset metadata. Store the URL or public_id in your application as needed. See the Cloudinary Node.js upload documentation for SDK options.

Choose a navigation wait condition

networkidle2 is a useful starting point, not a guarantee that every visual element is ready. Some sites keep requests open, load images lazily, or update the page after navigation. Match the wait condition and any additional readiness checks to the target:

  • domcontentloaded: proceed once the initial HTML has been parsed. Use it when the page keeps long-lived network connections, then wait for a specific selector or application-ready signal.
  • load: wait for the page’s load event, including load-dependent resources. A site may still render or fetch content afterward.
  • networkidle0 or networkidle2: wait for network activity to settle according to Puppeteer’s idle definition. Analytics, polling, ads, or streaming requests may make this unsuitable for some pages.

For a known page, wait for a meaningful element after navigation:

await page.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout: 60_000 });
await page.waitForSelector('main article', { timeout: 15_000 });
await page.screenshot({ path: 'article.png', fullPage: true });

For content that appears after a predictable delay, use a short explicit wait only after checking the page’s behavior:

await page.waitForTimeout(1_000);

Use Puppeteer’s current supported waiting APIs for your installed version. Avoid assuming that a fixed delay makes every page ready.

3. Capture a specific element

When you need a chart, card, or other component rather than the whole page, select it and call the element handle’s screenshot() method. The file can be uploaded with the same Cloudinary call as above.

const element = await page.waitForSelector('.product-card', { timeout: 15_000 });
if (!element) throw new Error('The product card was not found.');
await element.screenshot({ path: 'product-card.png', type: 'png' });
const uploaded = await cloudinary.uploader.upload('product-card.png', {
  resource_type: 'image',
});
console.log(uploaded.secure_url);

For a selector that can match multiple elements, use page.$() to get the first match, or evaluate the page to identify the desired instance. Make sure the selected element is visible and not covered if the captured result must match what a visitor sees. Puppeteer documents both page and element screenshots in its screenshot guide.

4. Upload screenshot bytes without a temporary file

page.screenshot() returns image bytes when no path or base64 output is requested. Convert those bytes to a Node.js Buffer, pass them to Cloudinary’s upload stream, and handle both stream errors and the upload callback:

const screenshotBytes = await page.screenshot({ type: 'png', fullPage: true });
const uploaded = await new Promise((resolve, reject) => {
  const stream = cloudinary.uploader.upload_stream(
    { resource_type: 'image' },
    (error, result) => {
      if (error) reject(error);
      else resolve(result);
    },
  );
  stream.on('error', reject);
  stream.end(Buffer.from(screenshotBytes));
});
console.log(uploaded.secure_url);

Put this after navigation and any readiness checks, while the page is still open. This approach avoids a temporary screenshot file, but the image bytes still occupy memory during capture and upload. The example combines Puppeteer’s byte-return behavior with Cloudinary’s documented upload_stream workflow; check module syntax and SDK behavior against the versions installed in your project. Puppeteer screenshot API.

5. Configure the screenshot for the intended use

Need Setting or method Notes
Whole page page.screenshot({ fullPage: true }) Captures beyond the current viewport. Very long pages can produce large images and use more memory.
Viewport only Omit fullPage or set it to false Set viewport dimensions before navigation or capture for predictable layout.
Element only elementHandle.screenshot() Wait for and select the component first.
PNG, JPEG, WebP type: 'png', 'jpeg', or 'webp' PNG suits sharp edges and text; JPEG is often smaller for photographs. Check format support and quality options in the installed Puppeteer version.
JPEG or WebP quality quality: 80 Quality is relevant to lossy formats, not PNG; choose based on acceptable visual detail and file size.
Retina-sized pixels deviceScaleFactor: 2 in setViewport() Raises pixel dimensions and memory use. Choose the target pixel density deliberately.

Cloudinary’s uploader accepts local paths and streams. Use resource_type: 'image' for screenshots. Avoid adding upload transformations unless they are part of the required output; retain the original if you need a reproducible capture.

6. cURL, Python, and Node.js alternatives

Puppeteer is a Node.js browser automation library, so its capture code runs in Node.js. cURL and Python can upload an image after it has been captured; they do not run Puppeteer directly. The examples below show the separate upload step and assume the screenshot file already exists.

Upload an existing screenshot with cURL

Cloudinary’s authenticated upload API uses a timestamp and signature generated with the API secret. Do not put the API secret in a public client. Generate a signature server-side using Cloudinary’s SDK or documented signing method; the placeholder below must be replaced with a valid signature for the included parameters.

curl -X POST "https://api.cloudinary.com/v1_1/YOUR_CLOUD_NAME/image/upload" \
  -F "file=@website-shot.png" \
  -F "api_key=YOUR_API_KEY" \
  -F "timestamp=UNIX_TIMESTAMP" \
  -F "signature=SERVER_GENERATED_SIGNATURE"

Upload an existing screenshot with Python

Install the SDK with python -m pip install cloudinary. Supply credentials through the environment and upload the file from a trusted server process:

import os
import cloudinary
import cloudinary.uploader

cloudinary.config(
    cloud_name=os.environ["CLOUDINARY_CLOUD_NAME"],
    api_key=os.environ["CLOUDINARY_API_KEY"],
    api_secret=os.environ["CLOUDINARY_API_SECRET"],
    secure=True,
)

result = cloudinary.uploader.upload(
    "website-shot.png",
    resource_type="image",
)
print(result["secure_url"])

Upload with Node.js using an SDK stream

For applications already using a different screenshot renderer, the upload step can accept a byte stream. The following is the SDK stream pattern:

import { v2 as cloudinary } from 'cloudinary';

cloudinary.config({
  cloud_name: process.env.CLOUDINARY_CLOUD_NAME,
  api_key: process.env.CLOUDINARY_API_KEY,
  api_secret: process.env.CLOUDINARY_API_SECRET,
});

const uploaded = await new Promise((resolve, reject) => {
  const stream = cloudinary.uploader.upload_stream(
    { resource_type: 'image' },
    (error, result) => error ? reject(error) : resolve(result),
  );
  stream.on('error', reject);
  stream.end(screenshotBuffer);
});
console.log(uploaded.secure_url);

For the end-to-end Puppeteer capture, use the Node.js examples above. Official references: Node.js upload guide and upload API and security.

7. Secure and reliable operation

  • Keep secrets server-side. The API secret is used to sign authenticated operations. Never send it to a browser or expose it in an endpoint response.
  • Validate caller-controlled URLs. If you expose this workflow as a service, restrict destinations and block access to internal networks and metadata endpoints. A browser that accepts arbitrary URLs can be abused to reach services the caller should not access.
  • Set bounded timeouts. Navigation and selector waits should fail within a known time so a stalled website does not hold a worker indefinitely.
  • Always close the browser. A finally block prevents errors from leaving Chromium processes running.
  • Handle upload errors separately. Navigation, screenshot creation, and Cloudinary upload are distinct failure points. Log a stage and a request identifier, but do not log credentials.
  • Retry only transient failures. Use a bounded retry with backoff for temporary network or service errors. Avoid blindly retrying invalid credentials, rejected parameters, or permanent target-page failures.
  • Protect against duplicate assets. If retries can repeat an upload, choose an application-level naming or overwrite policy and decide whether duplicate captures are acceptable.

8. Performance, reliability, and cost

Each capture launches or uses a browser, loads the target page, renders it, encodes an image, and uploads bytes. Page complexity, network conditions, image dimensions, and upload size affect time and memory; no single wait condition or capture time applies to every site.

  • Reuse a browser process for a controlled worker where appropriate, while creating isolated pages or contexts for separate jobs. Close pages and the browser during shutdown.
  • Keep the viewport and device scale factor no larger than the output needs. Full-page and high-density captures can use substantially more memory than viewport captures.
  • Prefer a specific readiness selector over an unnecessarily long network-idle wait when the page has a clear ready state.
  • Choose PNG, JPEG, or WebP based on the visual content and downstream needs. Compare output size for your actual pages before setting a default.
  • Cloudinary storage, transformations, and delivery are subject to the account’s current plan and usage terms. Check Cloudinary’s current pricing and limits for your workload; this research does not establish a price for a given capture.

9. Troubleshooting

Symptom Likely cause Fix
Navigation timeout The site is slow, keeps requests open, or the chosen idle condition never occurs. Try domcontentloaded and wait for a page-specific selector; keep a finite timeout.
Screenshot is blank or incomplete Capture ran before the visible content was rendered, or the site needs client-side data. Wait for a meaningful selector or application-ready state and inspect the page’s console or response status.
Lazy-loaded images are missing The images are outside the viewport and have not been requested. Scroll through the page before capture, wait for images to load, then capture. Verify this behavior on the target site.
Element selector times out The selector is wrong, conditional, or the component has not appeared. Check the selector in the page, wait for the correct state, and handle the missing element explicitly.
Cloudinary returns an authentication error Cloud name, API key, API secret, timestamp, or signature is missing or mismatched. Check server environment configuration and generate signatures with the matching parameters. Never move the secret into client code.
Upload rejects the file or request The upload parameters, file, or resource type are not accepted. Confirm the screenshot bytes are complete, use resource_type: 'image', and inspect the Cloudinary error response.
Browser fails to launch in deployment The runtime lacks required browser dependencies or launch configuration. Use a deployment environment compatible with Puppeteer/Chromium and follow its documented installation requirements.
Memory usage grows on long pages Full-page, high-density images and concurrent browser jobs consume memory. Reduce dimensions or concurrency, close pages promptly, and measure memory against representative pages.

Or skip the browser setup

If you only need the screenshot delivered, ScreenshotNeo provides a website screenshot API and MCP server. One GET request returns an image or PDF; this Node.js call saves the response bytes:

const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`ScreenshotNeo returned HTTP ${res.status}`);
await import('node:fs/promises').then(({ writeFile }) => writeFile('shot.webp', Buffer.from(await res.arrayBuffer())));

See the ScreenshotNeo API documentation for request options. It 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, and paid plans start at $5 for 3,000.

Create a free ScreenshotNeo account to get 1,000 screenshots a month with no card.

FAQ

Can Puppeteer capture a page and upload it without writing to disk?

Yes. Get the screenshot bytes from page.screenshot() and pass a Buffer to Cloudinary’s upload_stream(). This keeps the workflow in memory, though the image bytes still consume memory.

Should I capture a full page or an element?

Use a full-page screenshot for a page artifact and an element screenshot for a specific component such as a card or chart.

Can I run the upload from a browser?

Do not expose Cloudinary’s API secret in browser code. Perform signed uploads on a trusted server, or configure an unsigned upload preset if it fits your security and upload requirements.

Does networkidle2 guarantee that the screenshot is ready?

No. It only describes network activity. A page may render content later, so wait for a page-specific ready state when needed.