Puppeteer Screenshot Captures the Loading Spinner Instead of the Page
Wait for the page’s real ready condition before capturing. Use a spinner or content selector, diagnose timeouts, and see when network idle helps.
If Puppeteer captures a loading spinner, the screenshot is probably being taken before the site has reached the state you want. Wait for a page-specific signal before calling page.screenshot(): for a reliable spinner, wait until it is hidden; otherwise wait for the expected content or an application readiness condition. networkidle2 can help with navigation, but network quiet does not prove that the application has finished rendering.
Wait for the spinner or the content
Use the spinner’s actual CSS selector and wait for it to disappear. Then, when possible, also wait for the content the screenshot should contain. Replace the example URL and selectors with values from your page.
import puppeteer from 'puppeteer';
const url = 'https://example.com';
const spinnerSelector = '.loading-spinner';
const contentSelector = 'main';
const browser = await puppeteer.launch();
try {
const page = await browser.newPage();
await page.goto(url, { waitUntil: 'domcontentloaded' });
await page.waitForSelector(spinnerSelector, { hidden: true, timeout: 30000 });
await page.waitForSelector(contentSelector, { visible: true, timeout: 30000 });
await page.screenshot({ path: 'page.png', fullPage: true });
} finally {
await browser.close();
}
This is a template, not a reproduction against a particular site. If the spinner is not present on every load, a hidden wait can still succeed when it is absent. If it appears only after a delay, waiting for the expected content may be the more useful signal. Puppeteer documents that hidden: true resolves when the selected element is absent or hidden, and that selector waits time out if the condition is not met. The default selector wait timeout is 30 seconds. See the Puppeteer waitForSelector API.
Choose a readiness condition that matches the page
| Condition | Use it when | Limit |
|---|---|---|
| Spinner hidden | The spinner has a stable selector and its lifecycle tracks completion. | The spinner may disappear before the desired content is rendered, or another loading stage may follow. |
| Expected content visible | A stable result element indicates the output you need. | Choose a selector that represents useful content, not just a persistent page shell. |
| Application predicate | Readiness is represented by a state or combination of conditions. | The predicate must use state the target application actually exposes. |
| Specific response | A known API response is a prerequisite for the result. | A response arriving does not prove the browser has rendered the result. |
| Network idle | The page settles after a finite set of requests. | It measures network activity, not application readiness; analytics or long-lived requests may make it unsuitable. |
| Fixed delay | A known visual transition has a fixed duration and no observable completion signal. | It can be too short on a slow run and waste time on a fast one. |
Puppeteer’s screenshot guide shows navigation with waitUntil: 'networkidle2' before capture. Treat it as a navigation wait option, not a guarantee that a framework has completed its own updates. The Page API also documents waitForFunction(), waitForNetworkIdle(), and waitForResponse() as separate waits. See the Puppeteer screenshots guide and Puppeteer Page API.
Wait for a positive ready signal
If the page exposes a stable ready marker, waiting for it can describe the desired output more precisely than waiting for a generic spinner to go away. This example attribute is illustrative; use a selector that actually exists on the target page.
await page.goto(url, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('[data-page-ready="true"]', {
visible: true,
timeout: 30000
});
await page.screenshot({ path: 'page.png' });
For a state that cannot be expressed as one selector, use waitForFunction() with a condition grounded in the actual page. For example, if inspection confirms that a results element becomes non-empty:
await page.waitForFunction(() => {
const results = document.querySelector('#results');
return results && results.textContent.trim().length > 0;
}, { timeout: 30000 });
Do not copy a made-up global variable into a predicate. Inspect the application and wait for a real state it maintains.
Wait for a response when it is the prerequisite
If a known request produces the data needed for the screenshot, register the response wait before navigation or before the action that triggers the request, so the response cannot arrive before the wait starts.
const responsePromise = page.waitForResponse(response =>
response.url().includes('/api/results') && response.ok()
);
await page.goto(url, { waitUntil: 'domcontentloaded' });
await responsePromise;
await page.waitForSelector('#results', { visible: true });
await page.screenshot({ path: 'page.png' });
Replace /api/results and #results with the actual endpoint and rendered result. The response is a prerequisite in this example; the selector wait confirms the UI has rendered something before capture.
Diagnose the page before extending waits
- Confirm sequencing. Await navigation and all readiness waits before the screenshot. Capture on the same
Pagethat loaded the target URL. - Inspect the DOM. Find the real spinner selector and the content or state that means the page is ready. Check whether the spinner is removed, hidden with CSS, or covered by another loading layer.
- Check the condition manually. A selector typo, an iframe boundary, or a condition that never becomes true will cause a timeout. A longer timeout cannot repair a wrong condition.
- Inspect runtime behavior. If the expected marker never appears, look at page requests, console errors, authentication state, consent overlays, and navigation or frame behavior. These are possibilities to investigate, not assumptions about the cause.
- Keep the timeout finite. When a wait times out, use the failure to investigate the selector and page behavior before increasing the limit.
For a compact diagnostic run, log the current URL and inspect whether the selector exists after navigation:
await page.goto(url, { waitUntil: 'domcontentloaded' });
console.log('Loaded:', page.url());
console.log('Spinner count:', await page.locator('.loading-spinner').count());
console.log('Main count:', await page.locator('main').count());
Use selectors that match the target page. A count confirms whether an element is present; it does not establish that its content is correct or visible.
Common errors and fixes
| Symptom | Likely cause | Fix |
|---|---|---|
| Spinner still appears in the image | The screenshot runs after navigation but before the application’s ready state. | Await a verified spinner-hidden or expected-content condition before capture. |
Waiting for selector failed: timeout |
The selector is wrong, the element never reaches the requested state, or the page did not reach that stage. | Inspect the DOM and page behavior; correct the selector or wait for a real readiness signal. Extend the timeout only if the condition is correct and the page legitimately needs more time. |
| Wait resolves but the page is still incomplete | The spinner is only one of several loading stages, or it disappears before content is rendered. | Wait for the expected result element or a verified application predicate as well. |
networkidle2 resolves too early |
Network quiet occurred before client-side rendering or a later application task completed. | Keep it only if useful for navigation, then await the page-specific content condition. |
networkidle never resolves |
The page may keep requests active, such as polling or streaming. | Use a content or application readiness signal rather than requiring total network quiet. |
| The screenshot is blank or from another page | Navigation may have redirected, failed, or occurred in a different page or frame than expected. | Log page.url(), inspect navigation and runtime errors, and ensure capture uses the page that loaded the target. |
| The selector exists but is not visible | The element may be hidden, off-screen, or replaced during rendering. | Use a condition matching the required state, such as visible: true, and verify the selector identifies the final element. |
Performance, reliability, and capture details
- Prefer state-based waits. They avoid paying the time cost of a worst-case fixed delay on every capture while making failures easier to interpret.
- Use network idle selectively. It may be useful for pages that settle after finite requests; ongoing requests can prevent it from being a useful condition.
- Set finite timeouts. A stuck page should fail with a diagnosable timeout instead of waiting indefinitely. Choose a limit that fits your page’s expected behavior.
- Capture only after all required conditions. A screenshot is the rendered state at the moment of capture; Puppeteer does not decide whether that state is meaningful for your application.
- Mind capture scope. Puppeteer supports full-page and element screenshots. Use an element screenshot when only one ready component matters; use full-page capture when the entire rendered document is needed. See the screenshots guide.
- Account for the work you wait for. Waiting for more resources or a longer transition adds capture latency. Avoid redundant waits once a reliable readiness condition is established.
Or skip the browser setup
If you need a screenshot without maintaining Puppeteer setup, ScreenshotNeo takes a screenshot from one GET request. The URL below matches the Puppeteer example; change it to your target. See the ScreenshotNeo API documentation for request options.
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://example.com -o shot.webp
import requests
r = requests.get(
"https://api.screenshotneo.com/v1/shot",
params={"access_key": "YOUR_API_KEY", "url": "https://example.com"},
timeout=90,
)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://example.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot request failed: ${res.status}`);
const fs = await import('node:fs/promises');
await fs.writeFile('shot.webp', Buffer.from(await res.arrayBuffer()));
ScreenshotNeo accepts cookie and consent banners like a visitor and removes more than 60 known consent platforms, newsletter popups, and chat widgets before capture; each step can be turned off. Bot checks, blank pages, timeouts, failed loads, and cache hits cost nothing, and response headers report the page verdict and billing status. Its MCP server provides take_screenshot, get_page_info, and capture_pdf for AI agents. The free plan includes 1,000 screenshots per month with no card; paid plans start at $5 for 3,000. Every feature is on every plan. Learn more at ScreenshotNeo, then sign up for 1,000 free screenshots a month with no card.
FAQ
Does waitForSelector(..., { hidden: true }) wait for removal only?
No. Puppeteer documents it as resolving when the element is absent or hidden. It does not know whether the application’s work is complete.
Should I always use networkidle2?
No. It is useful when network activity is a suitable navigation condition. Pair it with a page-specific readiness signal when the rendered content matters.
What if the page has no spinner?
Wait for a stable element or application condition that represents the content you need in the screenshot.
Why can a response wait finish before the screenshot is ready?
The response can arrive before client-side code has updated the DOM. Wait for the rendered result as well.


