How to Capture a Screenshot and Send It to a Client with Node.js
Capture viewport, full-page, element, or clipped screenshots in Node.js, then upload the image safely as multipart/form-data.
To capture a screenshot and send it to a client with Node.js, launch a browser with Puppeteer or Playwright, navigate to the page, wait for the required state, capture the screenshot as bytes, and upload those bytes to the client’s endpoint as multipart/form-data. Keeping the image in memory avoids temporary-file cleanup.
This guide covers viewport, full-page, element, and clipped screenshots; PNG, JPEG, and WebP output; uploads with Playwright and Node’s built-in HTTP tools; browser-form uploads; reliability, performance, security, troubleshooting, and a hosted alternative.
1. Choose Puppeteer or Playwright
Both libraries automate Chromium-based screenshots. Playwright also supports Chromium, Firefox, and WebKit. Choose the library already used by your project, or use these practical criteria:
| Decision | Puppeteer | Playwright |
|---|---|---|
| Browser engines | Primarily Chromium | Chromium, Firefox, and WebKit |
| Screenshot API | page.screenshot() and element handles |
page.screenshot() and locator screenshots |
| Upload integration | Use Node fetch, an HTTP client, or a multipart package |
Use Node fetch or APIRequestContext |
| Best fit | Existing Puppeteer code or Chromium-only jobs | Cross-browser jobs and integrated request tooling |
2. Install dependencies
npm install playwright
npx playwright install chromium
For Puppeteer instead:
npm install puppeteer
Install the browser binary during image or machine setup, not during every request. In production, run the capture worker with the permissions and sandbox settings required by your deployment.
3. Capture and upload a full-page screenshot with Playwright
This complete example keeps the PNG in memory and posts it to a receiving API. Replace the endpoint, field name, and authentication with the client’s contract.
const { chromium } = require('playwright');
const targetUrl = process.env.TARGET_URL || 'https://example.com';
const uploadUrl = process.env.CLIENT_UPLOAD_URL || 'https://client.example/upload';
const uploadToken = process.env.CLIENT_UPLOAD_TOKEN;
(async () => {
const browser = await chromium.launch();
try {
const page = await browser.newPage({
viewport: { width: 1440, height: 900 },
deviceScaleFactor: 1,
});
await page.goto(targetUrl, {
waitUntil: 'networkidle',
timeout: 60_000,
});
// Prefer an application-specific readiness condition when available.
await page.locator('main').waitFor({ state: 'visible', timeout: 15_000 });
const bytes = await page.screenshot({
fullPage: true,
type: 'png',
});
const form = new FormData();
form.append('file', new Blob([bytes], { type: 'image/png' }), 'client-report.png');
form.append('source_url', targetUrl);
const response = await fetch(uploadUrl, {
method: 'POST',
headers: uploadToken ? { Authorization: `Bearer ${uploadToken}` } : {},
body: form,
signal: AbortSignal.timeout(60_000),
});
if (!response.ok) {
const body = await response.text();
throw new Error(`Upload failed (${response.status}): ${body}`);
}
console.log('Screenshot uploaded successfully');
} finally {
await browser.close();
}
})();
Node.js 18 and newer provide the global fetch, FormData, and Blob APIs used above. Do not set Content-Type manually: the runtime adds the multipart boundary.
4. Capture with Puppeteer
const puppeteer = require('puppeteer');
(async () => {
const browser = await puppeteer.launch();
try {
const page = await browser.newPage();
await page.setViewport({ width: 1440, height: 900, deviceScaleFactor: 1 });
await page.goto('https://example.com', {
waitUntil: 'networkidle2',
timeout: 60_000,
});
await page.waitForSelector('main', { visible: true, timeout: 15_000 });
const bytes = await page.screenshot({
fullPage: true,
type: 'png',
});
const form = new FormData();
form.append('file', new Blob([bytes], { type: 'image/png' }), 'client-report.png');
const response = await fetch('https://client.example/upload', {
method: 'POST',
body: form,
});
if (!response.ok) throw new Error(`Upload failed: ${response.status}`);
} finally {
await browser.close();
}
})();
5. Select the screenshot area
Viewport screenshot
const bytes = await page.screenshot({ type: 'png' });
This captures only the current viewport. Set an explicit viewport so output dimensions do not depend on the host machine.
Full-page screenshot
const bytes = await page.screenshot({ fullPage: true, type: 'png' });
Full-page mode captures the scrollable document. Pages with very large or virtualized content can produce large images or omit content that is rendered only after scrolling; wait for application readiness and test those pages explicitly.
Element screenshot
const report = page.locator('#report');
await report.waitFor({ state: 'visible' });
const bytes = await report.screenshot({ type: 'png' });
Element capture is useful for invoices, charts, dashboards, and report panels. Ensure the element is visible and has stable dimensions before capture.
Clipped rectangle
const bytes = await page.screenshot({
type: 'png',
clip: { x: 80, y: 120, width: 900, height: 500 },
});
The coordinates are CSS pixels relative to the page viewport. A clip outside the viewport or with zero dimensions causes an error.
6. Format, quality, and scaling
| Option | Use it when |
|---|---|
| PNG | You need lossless text, diagrams, or transparency. |
| JPEG | You need smaller photographic images. Set a quality value such as quality: 80. |
| WebP | Your receiving system and clients support it and transfer size matters. |
deviceScaleFactor: 1 |
You want predictable CSS-pixel dimensions. |
deviceScaleFactor: 2 |
You need a high-density image for a retina display. |
await page.setViewportSize({ width: 1280, height: 800 });
const jpeg = await page.screenshot({ type: 'jpeg', quality: 80, fullPage: true });
const webp = await page.screenshot({ type: 'webp', quality: 80, fullPage: true });
Use one format consistently for downstream processing. Keep the MIME type and filename extension aligned.
7. Wait for the right page state
networkidle is convenient, but analytics, chat, and streaming connections can prevent a page from becoming idle. Prefer a condition that represents the content your client needs:
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60_000 });
await page.locator('[data-report-ready="true"]').waitFor({ state: 'visible', timeout: 30_000 });
await page.waitForTimeout(500); // only for a known animation or font settling period
For lazy-loaded images, scroll through the page or wait for image completion before a full-page capture:
await page.evaluate(async () => {
const images = Array.from(document.images);
for (const image of images) image.scrollIntoView({ block: 'center' });
await Promise.all(images.map(image => image.complete
? Promise.resolve()
: new Promise(resolve => {
image.addEventListener('load', resolve, { once: true });
image.addEventListener('error', resolve, { once: true });
})));
});
8. Upload the bytes with Playwright’s request API
Playwright can send the captured bytes through an APIRequestContext. The exact field and authentication remain specific to the receiving service.
const { chromium, request } = require('playwright');
(async () => {
const browser = await chromium.launch();
const api = await request.newContext({
extraHTTPHeaders: { Authorization: `Bearer ${process.env.CLIENT_TOKEN}` },
});
try {
const page = await browser.newPage();
await page.goto('https://example.com', { waitUntil: 'networkidle', timeout: 60_000 });
const bytes = await page.screenshot({ fullPage: true, type: 'png' });
const response = await api.post('https://client.example/upload', {
multipart: {
file: { name: 'client-report.png', mimeType: 'image/png', buffer: bytes },
},
timeout: 60_000,
});
if (!response.ok()) throw new Error(`Upload failed: ${response.status()}`);
} finally {
await api.dispose();
await browser.close();
}
})();
9. Send a multipart request with Node’s HTTP client
Use this approach when you control the wire format or cannot use fetch. A multipart body consists of a boundary, headers for each part, the unchanged binary bytes, and a closing boundary.
const https = require('node:https');
function uploadMultipart({ url, fieldName, filename, mimeType, bytes, token }) {
return new Promise((resolve, reject) => {
const boundary = `----node-${Date.now().toString(16)}`;
const prefix = Buffer.from(
`--${boundary}\r\n` +
`Content-Disposition: form-data; name="${fieldName}"; filename="${filename}"\r\n` +
`Content-Type: ${mimeType}\r\n\r\n`,
);
const suffix = Buffer.from(`\r\n--${boundary}--\r\n`);
const body = Buffer.concat([prefix, Buffer.from(bytes), suffix]);
const target = new URL(url);
const req = https.request(target, {
method: 'POST',
headers: {
'Content-Type': `multipart/form-data; boundary=${boundary}`,
'Content-Length': body.length,
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
timeout: 60_000,
}, res => {
let responseBody = '';
res.setEncoding('utf8');
res.on('data', chunk => { responseBody += chunk; });
res.on('end', () => {
if (res.statusCode < 200 || res.statusCode >= 300) {
reject(new Error(`Upload failed (${res.statusCode}): ${responseBody}`));
} else {
resolve(responseBody);
}
});
});
req.on('timeout', () => req.destroy(new Error('Upload timed out')));
req.on('error', reject);
req.end(body);
});
}
For multiple fields, append another part before the closing boundary. For complex multipart contracts, a maintained multipart encoder reduces formatting mistakes.
10. Upload through a browser form
If “send it to a client” means submitting an HTML form, save the bytes first, then select the file input. Puppeteer supports uploadFile:
const fs = require('node:fs/promises');
const path = require('node:path');
const tempPath = path.join(process.cwd(), 'client-report.png');
await fs.writeFile(tempPath, bytes);
await page.locator('input[type="file"]').setInputFiles(tempPath);
await page.locator('form').locator('button[type="submit"]').click();
await page.waitForLoadState('networkidle');
await fs.unlink(tempPath).catch(() => {});
Use a unique temporary path for concurrent jobs and remove it in a finally block.
11. Reliability checklist
- Set an explicit viewport and device scale factor.
- Use a readiness selector or application signal instead of relying only on a generic network-idle state.
- Set navigation, selector, screenshot, and upload timeouts.
- Check the upload status and response body before reporting success.
- Close pages, contexts, API clients, and browsers in
finallyblocks. - Use HTTPS and the receiving client’s authentication mechanism.
- Treat screenshots as potentially sensitive client data; avoid logging image bytes or secrets in URLs.
- Use deterministic filenames and correct MIME types.
- Retry transient navigation or upload failures with a bounded retry count and backoff; do not blindly duplicate non-idempotent client operations.
12. Performance, scaling, and cost
- Launching a browser is expensive compared with reusing a browser process. A worker can reuse the process while creating a fresh context and page per job.
- Limit concurrent pages according to available CPU and memory. Full-page captures and high device scale factors increase memory use.
- Prefer element or clipped captures when the client does not need the entire document.
- Use JPEG or WebP when the receiving system accepts them and smaller transfers matter.
- Keep screenshot bytes in memory for an immediate upload; use disk only when another process requires a file.
- Measure navigation, rendering, encoding, and upload time separately in your deployment. The optimal concurrency depends on the target pages and machine size.
13. Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Browser executable not found | The browser binary was not installed in the environment. | Run the library’s browser install command during image setup and verify the executable path. |
| Navigation timeout | The site is slow, blocked, or keeps long-lived connections open. | Increase the timeout, use domcontentloaded, and wait for a specific readiness selector. |
| Blank or incomplete image | Capture ran before client-side rendering, fonts, or images completed. | Wait for an application signal, visible content, and image completion. |
| Full-page image misses lazy content | Content is loaded only after scrolling. | Scroll the document, wait for image loads, then capture. |
| Element not found | The selector is wrong or the element is inside a frame or shadow root. | Verify the selector, wait for visibility, and use the appropriate frame or locator. |
| Multipart endpoint rejects request | Wrong field name, MIME type, boundary, or authentication. | Match the client contract exactly and let fetch set the multipart content type. |
| Upload succeeds but file is corrupt | Binary bytes were converted to text or the content length is wrong. | Keep the result as a buffer or byte array and write it unchanged. |
| Out-of-memory errors | Too many concurrent pages, very tall documents, or high-density output. | Reduce concurrency, capture a smaller region, lower the scale factor, or encode a smaller format. |
| Form upload fails | The input is hidden, the path is relative, or the temporary file was deleted early. | Use an absolute path, set the file before submission, and clean up afterward. |
14. Or skip the browser setup
ScreenshotNeo provides a hosted screenshot API and MCP server. One GET request returns PNG, JPEG, WebP, or PDF bytes, so your Node.js service can forward the response to a client without installing or operating Chromium.
See the ScreenshotNeo API documentation for request options. Basic Node.js capture:
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 failed: ${res.status}`);
const image = Buffer.from(await res.arrayBuffer());
const form = new FormData();
form.append('file', new Blob([image], { type: res.headers.get('content-type') || 'image/webp' }), 'client-shot.webp');
const upload = await fetch('https://client.example/upload', { method: 'POST', body: form });
if (!upload.ok) throw new Error(`Client upload failed: ${upload.status}`);
ScreenshotNeo removes cookie and consent banners, newsletter popups, and chat widgets before capture. Bot checks, blank pages, failed loads, timeouts, and cache hits are not billed, and response headers identify the page verdict and billing result. Its MCP server lets Claude, Cursor, and other MCP clients call take_screenshot, get_page_info, and capture_pdf. The free plan includes 1,000 screenshots per month with no card; paid plans start at $5 for 3,000 screenshots.
Create a free ScreenshotNeo account and start with 1,000 screenshots a month at no charge.
15. FAQ
Should I save the screenshot to disk?
No, when the next step is an HTTP upload. Keep the returned bytes in memory and use a temporary file only when a browser form or another process requires a path.
Which format is safest for text-heavy pages?
PNG preserves text and edges without loss. Use JPEG or WebP when transfer size matters and the receiving system supports the format.
Can I capture a page that requires authentication?
Yes. Establish the authenticated browser context with the required cookies, headers, or login flow before navigation, and protect the resulting image as sensitive data.
Why does network idle never happen?
Analytics, chat, streaming, or polling connections can remain open. Navigate with a less strict wait state and wait for a selector or application-ready signal instead.
How do I prevent duplicate client uploads?
Use an idempotency key if the receiving API supports one, or store a job identifier and make retries conditional on the previous response.


