Puppeteer Screenshot Shows Broken Images After a Redirect
A redirect can finish successfully while image requests fail or images remain unready. Trace the final page, inspect image loads, and wait for the state your screenshot needs.
Short answer: Puppeteer follows server redirects during page.goto(), and its returned response describes the final main document. That does not prove that the destination is the page you expected or that its images loaded. Log the final URL and document status, inspect image requests and image elements, then wait for the specific page and image state required before capturing.
The redirect itself is not enough information to identify the cause. Broken images can result from a failed or blocked image request, an unexpected destination page, application state that has not finished rendering, or lazy-loaded images that have not been brought into view. Treat each as a hypothesis and collect evidence from the run.
1. What Puppeteer’s redirect response tells you
page.goto(url) returns the main-resource response. If navigation followed multiple redirects, Puppeteer documents that this is the response for the last redirect in the chain. Check that response’s status and page.url() after navigation. A 2xx document response still says nothing conclusive about separate image requests.
Chromium handles a redirect as another request using the redirect response’s Location header. After navigation commits, the browser parses and renders the document, runs scripts, and loads subresources. Lifecycle events such as DOMContentLoaded can happen before subresources finish loading. See the [Puppeteer Page.goto() API](https://github.com/puppeteer/puppeteer/blob/main/docs/api/puppeteer.page.goto.md) and [Chromium’s navigation documentation](https://chromium.googlesource.com/chromium/src/%2B/83.0.4103.106/docs/navigation.md).
2. Capture navigation and image diagnostics
This runnable CommonJS example logs the final URL and main-document status, failed requests, image response failures, and the state of image elements before saving a screenshot. Install Puppeteer with npm install puppeteer, save this as capture.cjs, then run node capture.cjs https://example.com. Replace the sample URL with the URL that reproduces the problem.
const puppeteer = require('puppeteer');
(async () => {
const inputUrl = process.argv[2];
if (!inputUrl) throw new Error('Usage: node capture.cjs <url>');
const browser = await puppeteer.launch({ headless: true });
try {
const page = await browser.newPage();
page.on('requestfailed', request => {
console.error('REQUEST FAILED', request.resourceType(), request.url(),
request.failure()?.errorText);
});
page.on('response', response => {
if (response.request().resourceType() === 'image' && response.status() >= 400) {
console.error('IMAGE HTTP ERROR', response.status(), response.url());
}
});
const response = await page.goto(inputUrl, {
waitUntil: 'networkidle2',
timeout: 60000,
});
console.log({
requestedUrl: inputUrl,
finalUrl: page.url(),
documentStatus: response?.status() ?? null,
documentOk: response?.ok() ?? false,
});
const title = await page.title();
console.log('title:', title);
// Replace this selector with a required page-specific element when possible.
// A title alone may not prove that the intended application view rendered.
const expectedSelector = process.env.EXPECTED_SELECTOR;
if (expectedSelector) {
await page.waitForSelector(expectedSelector, { timeout: 15000 });
}
// This reports image element state. It does not force off-screen lazy images
// to load; use the next section if the page uses lazy loading.
const images = await page.$$eval('img', elements => elements.map(img => ({
src: img.currentSrc || img.src,
complete: img.complete,
naturalWidth: img.naturalWidth,
naturalHeight: img.naturalHeight,
loading: img.loading,
})));
console.log('images:', JSON.stringify(images, null, 2));
await page.screenshot({ path: 'capture.png', fullPage: true });
} finally {
await browser.close();
}
})().catch(error => {
console.error(error);
process.exitCode = 1;
});
networkidle2 is the wait condition shown in Puppeteer’s [screenshot guide](https://pptr.dev/guides/screenshots). It is a useful starting point, not a guarantee that lazy images, application-rendered content, or every image on every site is ready. The main-document status also needs interpretation: Puppeteer notes that a valid HTTP error response such as 404 or 500 does not necessarily make headless shell throw, so inspect the status yourself.
3. Check the final destination and expected page
- Compare
page.url()with the destination you expect. A redirect can lead to a sign-in page, challenge page, error page, or a different locale or route. - Record the final main-document status from the returned response. Handle a missing response separately, as can happen with certain navigation types such as a same-document navigation.
- Check a page-specific marker, such as a product heading or application container. Use
waitForSelector()for a selector that only exists in the intended view. A page title or successful HTTP status alone may be insufficient. - Inspect the redirect chain and image URLs in the browser’s request log. Compare the requested image URL with the final image URL and response status where available.
- Check failures and blocked requests. A failed request can expose a browser error; an HTTP error response gives a status. They are different signals and should be logged separately.
For each failed image, investigate the concrete URL and error before changing the wait strategy. Access restrictions, expired or host-restricted image URLs, mixed HTTP/HTTPS behavior, and blocked requests are possibilities to test, not conclusions that follow from the title alone.
4. Wait for the images your screenshot needs
For ordinary images already requested by the page, wait for image elements to finish and verify that they have usable dimensions. For a page where all currently present images matter, add this after navigation and any required application selector:
await page.waitForFunction(() => {
const images = [...document.images];
return images.every(img => img.complete);
}, { timeout: 15000 });
const imageState = await page.$$eval('img', images => images.map(img => ({
src: img.currentSrc || img.src,
complete: img.complete,
naturalWidth: img.naturalWidth,
naturalHeight: img.naturalHeight,
})));
const unusable = imageState.filter(img =>
!img.complete || img.naturalWidth === 0 || img.naturalHeight === 0
);
if (unusable.length) {
throw new Error(`Images not usable: ${JSON.stringify(unusable)}`);
}
A broken image can also have complete === true; zero naturalWidth or naturalHeight is why the snippet checks dimensions as well. This check only covers image elements present in the DOM when it runs. If the site adds images later, wait for the page’s own ready marker or for the relevant elements before checking again.
Lazy-loaded and below-the-fold images
Lazy-loaded images may not be requested until they approach the viewport. For full-page screenshots, scroll through the page to trigger loading, then return to the top and wait for image completion. Scrolling can trigger other page behavior, so use a page-specific method if the site has infinite scrolling or changes content as you scroll.
await page.evaluate(async () => {
const step = Math.max(window.innerHeight, 500);
for (let y = 0; y < document.body.scrollHeight; y += step) {
window.scrollTo(0, y);
await new Promise(resolve => setTimeout(resolve, 100));
}
window.scrollTo(0, 0);
});
await page.waitForFunction(() =>
[...document.images].every(img => img.complete),
{ timeout: 20000 }
);
await page.screenshot({ path: 'full-page.png', fullPage: true });
The short pause gives scroll-triggered page code a chance to run; it is not a universal readiness guarantee. Increase or replace it only after observing the page’s behavior. If the page continuously appends content, use a bounded scroll strategy and an application-specific stopping condition to avoid an unending capture.
5. Choose a wait condition that matches the page
| Condition | What it tells you | When to use it | Limit |
|---|---|---|---|
domcontentloaded |
The initial document has been parsed. | Fast navigation when the page’s scripts and assets are not yet needed. | It can precede image and other subresource completion. |
load |
The page load event has fired after its dependent resources. | Pages with conventional load behavior. | It does not establish that lazy or later application images are ready. |
networkidle2 |
Network activity has reached Puppeteer’s network-idle condition. | A useful initial screenshot wait, as in the official guide. | Long polling and continuously active pages can prevent it; quiet networking does not prove the correct visual state. |
| Selector or app-ready state | A chosen element or site-specific condition is present. | Single-page apps and routes with a clear rendered-state marker. | Choose a marker that means the needed content is actually ready. |
| Image readiness check | Present image elements are complete and have nonzero dimensions. | When image completeness is essential to the output. | It does not discover images that have not yet been inserted or lazy-loaded. |
Use more than one signal when the page needs it: for example, wait for navigation to reach networkidle2, wait for the expected content selector, trigger lazy loading if necessary, then validate important image dimensions. Avoid adding fixed delays as the only synchronization mechanism; a delay can still be too short on a slow run and unnecessarily long on a fast one.
6. Troubleshooting common symptoms
| Symptom | Likely diagnostic direction | What to do |
|---|---|---|
| Final URL is unexpected | The redirect ended on another route or page. | Inspect the final URL and document response; verify the expected page selector before capture. |
| Main response is 404 or 500, but navigation did not throw | A valid HTTP error response is not necessarily a navigation exception. | Check response.status() and stop or report when the status is outside the range your workflow accepts. |
| Document is successful but image is broken | The document and image are separate requests. | Log image request failures and image response statuses; inspect the exact image URL and its response. |
| Only below-the-fold images are missing | They may be lazy-loaded and never approached the viewport. | Scroll through the relevant page area, wait for those image elements, then capture. |
networkidle2 times out |
The site may keep network requests active, or the destination may not settle. | Check the final URL and request log. Use a page-specific selector and image checks where appropriate instead of relying only on network idle. |
Image request is net::ERR_BLOCKED_BY_CLIENT |
A browser or environment block may be involved. Puppeteer documents a version- and environment-dependent Chrome for Testing HTTPS-first case for some remote HTTP navigations. | Check the failed URL and whether the redirect targets HTTP. Review Puppeteer’s [troubleshooting notes](https://github.com/puppeteer/puppeteer/blob/main/docs/troubleshooting.md); do not assume this is the cause without matching evidence. |
| Screenshot is blank or shows a challenge/login | The destination may not be the intended application state. | Inspect final URL, title, status, and expected page marker before saving the image. |
| Wait for all images never finishes | An image may be continually added, or the page may have changing content. | Limit the check to required image selectors or a bounded page region and fail with the URLs of unusable images. |
| Screenshot intermittently misses images | Timing, lazy loading, or variable response time may be involved. | Log image state on each run, wait for required content and images, and set timeouts from observed page behavior. |
7. Reliability, performance, and cost considerations
- Fail with evidence: For automated capture, log the requested URL, final URL, document status, failed requests, image status, and unusable image URLs. This makes intermittent failures diagnosable.
- Keep waits bounded: Use explicit timeouts and handle timeout errors so a page that never becomes ready cannot stall a batch indefinitely.
- Wait only for what matters: Waiting for every image on a long page can increase capture time and fail because of unrelated assets. Check the content region or selectors the screenshot is intended to show.
- Reuse the browser for batches: Launching Chromium has setup cost. When capturing multiple pages in one process, reuse a browser and create a fresh page per capture, while closing pages and the browser reliably.
- Do not treat a screenshot as proof of correctness: Save diagnostics alongside the output or return an explicit failed-capture result when required content did not load.
- Cost: Running Puppeteer yourself means operating the browser environment and accounting for its compute and storage in your own infrastructure. Actual cost depends on your runtime, concurrency, and capture workload; no universal benchmark or price follows from this diagnosis.
8. Or skip the browser setup
If the task is simply to get a website screenshot, [ScreenshotNeo](https://screenshotneo.com) provides a one-request screenshot API and an MCP server. Its [API documentation](https://screenshotneo.com/docs/) covers the available parameters. This cURL example saves a WebP screenshot:
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
Equivalent 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)
Equivalent 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}`);
if (!res.ok) throw new Error(`Screenshot request failed: ${res.status}`);
await import('node:fs/promises').then(fs => fs.writeFile('shot.webp', Buffer.from(await res.arrayBuffer())));
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; response headers report the page verdict and billing status. Its MCP tools let AI agents use take_screenshot, get_page_info, and capture_pdf. The free plan includes 1,000 screenshots a month with no card; paid plans start at $5 for 3,000 screenshots.
Sign up for 1,000 free screenshots a month, with no card required.
9. Frequently asked questions
Does page.goto() follow redirects?
Yes. It returns the main-resource response for the final redirect in the chain. Check page.url() and the response status to see where navigation ended.
Does a 200 status mean the screenshot will have every image?
No. It describes the main document response. Check image requests and the image elements your screenshot depends on.
Is networkidle2 always enough?
No. It is an official screenshot-guide example and a reasonable starting point, but it cannot establish that lazy-loaded images or the intended application state are ready.
What information is needed to identify the exact cause?
The requested and final URLs, document response status, failed-request log, image response statuses and URLs, and the capture script’s wait condition. The title alone does not identify the root cause.


