Fix Blank Website Screenshots in Puppeteer on AWS Lambda
A blank Puppeteer screenshot can mean the page never loaded, JavaScript was still rendering, or the image was saved incorrectly. Diagnose each stage in AWS Lambda.
A blank screenshot in Puppeteer on AWS Lambda is a symptom, not a diagnosis. The browser may have captured before the page rendered, received an error or bot challenge instead of the expected page, launched with an incompatible Chromium bundle, or saved an output your downstream code cannot read. Check navigation status, page readiness, browser errors, and screenshot bytes in that order. Then check Lambda resources and artifact storage if the capture itself appears correct.
This guide applies to a customer-packaged Puppeteer Lambda function. CloudWatch Synthetics is a separate managed workflow with its own browser versions, timeouts, and artifact permissions.
1. Trace the request before changing the screenshot code
First establish whether the browser reached the expected page. Record the requested URL, response status, final URL after redirects, page title, failed requests, console errors, and whether a known page element exists. A screenshot of a 403, CAPTCHA, login page, or error document is a valid screenshot of the wrong content; it does not by itself indicate a broken screenshot API.
- Log the navigation response and final URL.
- Wait for an application-specific visible selector or ready signal.
- Log page and request errors, and capture a diagnostic image before the final capture.
- Verify the returned bytes or local file before investigating upload or display code.
Puppeteer’s screenshot guide demonstrates navigation with waitUntil: 'networkidle2', but that is an example, not a universal readiness rule. AWS’s canary sample uses domcontentloaded followed by an explicit wait. Prefer a stable selector or app-specific signal when the target has client-side rendering; choose network idle only when the page’s network behavior makes it meaningful. [Puppeteer screenshot guide](https://pptr.dev/guides/screenshots)
2. Add diagnostics to the Lambda handler
The following CommonJS handler shows the diagnostic sequence. It expects puppeteer-core and @sparticuz/chromium to be packaged as compatible dependencies for the deployed Node.js runtime and architecture. Puppeteer’s Lambda troubleshooting page points to Sparticuz Chromium as a community option; it does not define one universally correct version pairing or launch configuration. Check the versions and deployment instructions for the exact packages you ship. [Puppeteer Lambda troubleshooting](https://pptr.dev/troubleshooting)
// handler.cjs
const puppeteer = require('puppeteer-core');
const chromium = require('@sparticuz/chromium');
exports.handler = async (event) => {
const url = event.url;
if (typeof url !== 'string' || !/^https?:\/\//i.test(url)) {
throw new Error('Pass an http or https URL in event.url');
}
let browser;
try {
browser = await puppeteer.launch({
args: chromium.args,
defaultViewport: { width: 1365, height: 900 },
executablePath: await chromium.executablePath(),
headless: true,
});
const page = await browser.newPage();
page.setDefaultNavigationTimeout(30000);
page.setDefaultTimeout(15000);
page.on('pageerror', error => console.error('PAGE_ERROR', error.message));
page.on('console', message => {
if (message.type() === 'error') console.error('CONSOLE_ERROR', message.text());
});
page.on('requestfailed', request => console.error(
'REQUEST_FAILED', request.url(), request.failure()?.errorText
));
const response = await page.goto(url, { waitUntil: 'domcontentloaded' });
console.log('NAVIGATION', JSON.stringify({
requestedUrl: url,
status: response?.status() ?? null,
finalUrl: page.url(),
title: await page.title(),
}));
if (!response) throw new Error('Navigation returned no main-resource response');
if (!response.ok()) throw new Error(`Main document returned HTTP ${response.status()}`);
// Replace this with a selector or ready signal that proves your app is rendered.
await page.waitForSelector('main', { visible: true });
console.log('READY', JSON.stringify({
finalUrl: page.url(),
title: await page.title(),
mainCount: await page.locator('main').count(),
}));
await page.screenshot({ path: '/tmp/diagnostic.png', fullPage: true });
const bytes = await page.screenshot({ type: 'png', fullPage: true });
console.log('SCREENSHOT_BYTES', bytes.length);
if (bytes.length === 0) throw new Error('Screenshot returned zero bytes');
// Return bytes through your chosen integration. This example returns base64.
return {
statusCode: 200,
headers: { 'content-type': 'image/png' },
isBase64Encoded: true,
body: Buffer.from(bytes).toString('base64'),
};
} finally {
if (browser) await browser.close();
}
};
For this handler, main is only an example readiness selector. Replace it with an element that appears after the actual content is ready. If the application has no stable selector, expose a page-ready signal or use a carefully chosen wait condition and a bounded delay. Always await navigation, readiness, screenshot capture, and any upload before closing the browser or returning from the handler.
3. Check whether the page is ready
Choose a wait condition that matches the site
| Approach | Useful when | Watch for |
|---|---|---|
domcontentloaded |
You will wait for a known app element after initial HTML parsing. | Images, fonts, scripts, and client rendering may still be in progress. |
networkidle2 |
The page settles to no more than two active network connections and its requests are finite. | Analytics, polling, streaming, or other long-lived requests can delay or prevent idle. |
waitForSelector |
A stable visible element marks usable content. | A selector can be wrong, hidden, or present before the full app is ready. |
| App-ready signal | Your application can signal that the required data and rendering are complete. | Instrument and maintain the signal as the application changes. |
| Fixed delay | A short, bounded delay covers a known animation or late render with no better signal. | It can still be too short, and longer delays consume Lambda time and cost. |
Do not replace diagnosis with a large sleep. If a selector wait times out, inspect a screenshot and logs from just before the timeout, then verify the selector and whether the page can access the resources it needs. CloudWatch Synthetics troubleshooting likewise recommends inspecting logs and screenshots and verifying the selector or XPath when an element wait times out. [AWS canary troubleshooting](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch_Synthetics_Canaries_Troubleshoot.html)
Distinguish a blank document from a blocked page
Check the main response status and final URL. Then inspect the diagnostic image, title, visible text, and expected selector. A redirect to authentication, an access-denied response, or a challenge page points to access policy or network conditions. If the destination uses AWS WAF, coordinate an approved user agent or allow rule with the site owner; do not try to evade access controls. AWS specifically recommends a site-approved custom user agent for its canary traffic. [AWS canary troubleshooting](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch_Synthetics_Canaries_Troubleshoot.html)
4. Verify the Lambda browser deployment
A browser that works locally may fail in Lambda because the deployed artifact has different dependencies, binary paths, architecture, runtime, or system libraries. Puppeteer documents Lambda deployment package size constraints and links to the community Sparticuz Chromium project as one option. Treat any example launch settings as package-specific: verify the Chromium binary path and arguments against the version you actually deployed. [Puppeteer troubleshooting](https://pptr.dev/troubleshooting)
- Confirm the deployed zip, layer, or container contains the intended Puppeteer and Chromium packages.
- Match Node.js runtime and CPU architecture to the artifacts you built.
- Verify
executablePath, launch arguments, and any required writable temporary storage. - Check package compatibility and release notes whenever updating Puppeteer, Chromium, Node.js, or architecture.
- Reproduce using the deployed artifact and runtime where possible; local success alone does not validate Lambda packaging.
CloudWatch Synthetics bundles its own runtime versions. For example, the dossier records syn-nodejs-puppeteer-12.0 with Node.js 22.x, Puppeteer-core 24.22.1, and Chromium 140.0.7339.185. That managed pairing is not a compatibility matrix for a separately packaged Lambda function. [AWS Synthetics runtime versions](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch_Synthetics_Library.html)
5. Confirm capture scope and output
Puppeteer’s Page.screenshot() returns image data; its options determine format and capture behavior. Check these settings against the actual use case. [Puppeteer screenshot API](https://pptr.dev/api/puppeteer.page.screenshot)
| Setting or output | What to verify |
|---|---|
fullPage |
It is optional and false by default. Enable it when the whole document is needed; check for lazy-loaded content below the viewport. |
clip |
A clip restricts the captured region. Confirm its coordinates and dimensions overlap visible content. |
omitBackground |
When enabled, the default background is omitted; a transparent image can look blank on a white viewer. |
path |
Use an explicit writable path such as /tmp/diagnostic.png in Lambda, and verify that the file exists and has nonzero size. |
| Returned bytes | Log the byte count and content type. Verify your HTTP integration, base64 handling, upload, and viewer separately. |
| Viewport and device scale | Set a deliberate viewport and scale if layout or image dimensions differ from local output. |
Capture a small diagnostic screenshot before a full-page image. If the returned bytes are nonzero but your browser, storage, or API client shows an empty result, inspect the response headers and encoding path rather than the page render.
6. Check Lambda capacity and timeout
Measure invocation duration, peak memory, and where time is spent: cold start, Chromium launch, navigation, readiness wait, capture, or upload. Lambda allocates CPU in proportion to configured memory, so a slow render can reflect CPU or memory pressure. Increase memory only after checking measurements, then compare duration and memory use. [AWS Lambda memory configuration](https://docs.aws.amazon.com/lambda/latest/dg/configuration-memory.html)
Set the Lambda timeout to cover browser startup, the slowest expected navigation, readiness, capture, and output handling, with room for normal variation. A timeout can terminate work before a screenshot or upload completes. For CloudWatch Synthetics specifically, AWS recommends a timeout of at least 15 seconds to allow for cold starts and canary instrumentation boot; that guidance is specific to Synthetics and is not a universal timeout for every Puppeteer Lambda function. [AWS canary troubleshooting](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch_Synthetics_Canaries_Troubleshoot.html)
7. If you use CloudWatch Synthetics, check its separate failure modes
Do not apply Synthetics artifact advice to ordinary Lambda file writes. In a Synthetics canary, the browser may have produced a screenshot while the canary fails to publish its artifacts. Review the failed run’s screenshots, logs, HAR data, and step report. For S3 upload access errors, AWS lists permissions including s3:GetBucketLocation and s3:PutObject; visual monitoring can also require s3:GetObject. Customer-managed KMS keys and bucket policies can also block uploads. [AWS canary troubleshooting](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch_Synthetics_Canaries_Troubleshoot.html)
For a canary reporting Target closed, make sure all asynchronous page work and screenshot calls are awaited before the browser closes. For VPC connectivity issues, inspect the canary’s logs and network path; Synthetics has its own VPC and endpoint requirements.
8. Common errors and fixes
| Symptom | Likely cause | Next check or fix |
|---|---|---|
| Screenshot is all white, but call succeeds | Capture happened before client rendering, or the app is genuinely empty. | Wait for an app-ready selector; log title and expected content; capture a diagnostic image before and after readiness. |
| Screenshot shows an access-denied or challenge page | The site rejected the request, redirected it, or requires authentication. | Inspect status and final URL; confirm permitted access and site-approved request identity with the site owner. |
Navigation timeout |
Slow target, long-lived requests, blocked resources, or an unsuitable wait condition. | Inspect failed requests; use an appropriate navigation condition followed by a meaningful readiness signal; keep the overall timeout bounded. |
Waiting for selector failed |
Wrong selector, hidden element, content not loaded, or different response page. | Inspect the diagnostic screenshot, title, URL, and status; verify the selector against the rendered page. |
Failed to launch or missing executable |
Chromium is absent, path or permissions are wrong, or package/runtime/architecture do not match. | Inspect the deployed artifact and binary path; align the Chromium package with the Lambda runtime and architecture. |
Target closed |
Browser or page closed while an asynchronous operation was still running. | Await navigation, waits, screenshot, and upload; close the browser in a finally block after work completes. |
| File is missing or zero bytes | Wrong output path, write not awaited, or handler ended before output completed. | Use a writable explicit path; await the operation; check file size or returned buffer length. |
| Image data exists but delivered image is blank | Encoding, response content type, base64 flag, storage, or viewer issue. | Inspect the original PNG bytes and HTTP/storage metadata, then trace each conversion separately. |
| Works locally, times out in Lambda | Cold start, lower compute allocation, slow network, or Lambda-specific packaging. | Measure stages and memory; verify egress and dependencies; tune memory and timeout based on observed duration. |
| Synthetics screenshot exists but artifact upload fails | S3, bucket policy, VPC endpoint, or KMS permissions. | Check the canary role and relevant bucket/key policies using AWS’s Synthetics permissions guidance. |
9. Performance, reliability, and cost
- Wait efficiently: a selector that represents actual readiness avoids both premature capture and unnecessary fixed sleeps. Bound navigation and selector waits so one hung target does not consume the entire invocation.
- Measure before scaling: record stage durations and peak memory. More Lambda memory also allocates more CPU, but choose a setting based on observed runtime and memory needs.
- Control concurrency: each concurrent browser consumes memory and network capacity. Size reserved concurrency and downstream limits for your workload rather than assuming a browser is lightweight.
- Keep failures distinguishable: log structured status, URL, timings, and error categories. Separate a blocked or unsuccessful page from a successful capture whose upload failed.
- Budget for whole invocations: browser startup, navigation, readiness, and image output all contribute to execution time and resource use. The sources here do not establish a universal cost per screenshot; calculate from your Lambda configuration, invocation duration, storage, and network usage.
10. Or skip the browser setup
ScreenshotNeo is a website screenshot API and MCP server. One GET request returns an image or PDF, so you do not need to package Chromium or manage its Lambda launch configuration. See the 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}`);
if (!res.ok) throw new Error(`Screenshot request failed: ${res.status}`);
await import('node:fs/promises').then(async fs => fs.writeFile('shot.webp', Buffer.from(await res.arrayBuffer())));
Cookie banners are accepted and removed before capture, along with known consent platforms, newsletter popups, and chat widgets; each cleanup step can be turned off. Bot checks, blank pages, failed loads, timeouts, and cache hits are not billed, and response headers say the page verdict and billing status. Its MCP server gives AI agents tools for screenshots, page information, and PDF capture. The free plan includes 1,000 screenshots a month with no card; paid plans start at $5 for 3,000 screenshots.
Sign up free for 1,000 screenshots a month, with no card.
FAQ
Does a successful page.screenshot() call prove the website loaded?
No. It proves the capture call returned; inspect the navigation response and rendered content separately.
Should I always use networkidle2?
No. It is useful when the page’s requests settle, but polling or long-lived connections can make it a poor fit. Match readiness to the target application.
Is CloudWatch Synthetics the same as deploying Puppeteer in my Lambda?
No. Synthetics is managed and has its own runtime bundle and artifact workflow. A customer-packaged function must supply and maintain a compatible browser deployment.
Can I identify the cause without logs or the target URL?
Not reliably. The same blank image can result from failures at navigation, rendering, capture, or delivery. The diagnostic sequence narrows the stage before changing code.


