Puppeteer Screenshot Still Shows the Consent Wall After Accept? Fix It
A Puppeteer click can finish before a consent flow does. Coordinate navigation, wait for the real post-consent state, and verify it before capturing.
If your Puppeteer screenshot still shows the consent wall after clicking “Accept,” the click probably completed before the consent flow did. Treat clicking, waiting, and capturing as separate steps: locate the real accept control, coordinate any navigation it triggers, wait for a site-specific post-consent state, confirm the wall is gone, and only then call page.screenshot().
The right selector and wait condition depend on the site. A button may be inside an iframe, dismiss the banner without navigating, redirect or reload, or save consent state somewhere other than cookies. There is no reliable universal accept selector or consent cookie name.
1. Install Puppeteer and identify the accept control
This runnable example uses an environment variable for the target URL and selector. Set both to values from the page you are capturing. The selector must identify the actual accept button in the live page DOM; do not copy a guessed selector from another site.
npm install puppeteer
# Set these for your shell before running the script:
export TARGET_URL='https://example.com'
export ACCEPT_SELECTOR='button#accept-cookies'
export CONSENT_SELECTOR='[role="dialog"]'
export READY_SELECTOR='main'
# Save the JavaScript below as capture.mjs, then run:
node capture.mjs
Save this as capture.mjs:
import puppeteer from 'puppeteer';
const url = process.env.TARGET_URL;
const acceptSelector = process.env.ACCEPT_SELECTOR;
const consentSelector = process.env.CONSENT_SELECTOR;
const readySelector = process.env.READY_SELECTOR;
if (!url || !acceptSelector || !consentSelector || !readySelector) {
throw new Error(
'Set TARGET_URL, ACCEPT_SELECTOR, CONSENT_SELECTOR, and READY_SELECTOR.'
);
}
const browser = await puppeteer.launch({ headless: true });
try {
const page = await browser.newPage();
page.setDefaultTimeout(10_000);
page.setDefaultNavigationTimeout(30_000);
await page.goto(url, { waitUntil: 'domcontentloaded' });
await page.waitForSelector(acceptSelector, { visible: true });
// Use this branch when clicking Accept is expected to navigate or reload.
// Start both waits together to avoid missing a fast navigation.
const [response] = await Promise.all([
page.waitForNavigation({
waitUntil: 'domcontentloaded',
timeout: 15_000,
}),
page.click(acceptSelector),
]).catch(async error => {
// A timeout can mean the site dismissed the banner in-place rather than
// navigating. Continue to the site-specific UI checks below only for that
// expected case; rethrow other failures.
if (error.name !== 'TimeoutError') throw error;
return [null];
});
// Wait for the consent UI to disappear and for meaningful page content.
// Adjust these selectors to match the target site.
await page.waitForFunction(
selector => {
const element = document.querySelector(selector);
if (!element) return true;
const style = getComputedStyle(element);
const rect = element.getBoundingClientRect();
return style.display === 'none' ||
style.visibility === 'hidden' ||
Number(style.opacity) === 0 ||
rect.width === 0 || rect.height === 0;
},
{ timeout: 15_000 },
consentSelector,
);
await page.waitForSelector(readySelector, { visible: true });
const consentStillVisible = await page.$eval(consentSelector, element => {
const style = getComputedStyle(element);
const rect = element.getBoundingClientRect();
return style.display !== 'none' &&
style.visibility !== 'hidden' &&
Number(style.opacity) !== 0 &&
rect.width > 0 && rect.height > 0;
}).catch(() => false);
if (consentStillVisible) {
throw new Error('Consent UI is still visible; refusing to save a misleading screenshot.');
}
await page.screenshot({ path: 'result.png', fullPage: true });
console.log(`Saved result.png${response ? ' after navigation' : ' after in-page update'}`);
} finally {
await browser.close();
}
The example demonstrates the checks, but its navigation branch is only appropriate if navigation is plausible for that button. If you know the site updates the current document without navigation, use the in-page version below instead. Its timeout is a maximum wait, not a claim that every site needs that long.
2. Choose the correct wait for the consent flow
When the click navigates or reloads
Puppeteer documents a race if you await the click and only then begin waiting for navigation. Create both promises before awaiting either:
const [response] = await Promise.all([
page.waitForNavigation({ waitUntil: 'domcontentloaded' }),
page.click(acceptSelector),
]);
The response can be null, for example when navigation is a same-document history change. Pick a navigation condition suitable for the site. domcontentloaded waits for parsing; it does not guarantee that client-rendered content or images are ready. See the [Puppeteer Page API](https://pptr.dev/api/puppeteer.page) and [waitForNavigation API](https://pptr.dev/api/puppeteer.frame.waitfornavigation).
When the banner updates in place
A fulfilled page.click() means Puppeteer performed the click, not that the application finished its asynchronous consent work. Wait for evidence of the outcome, such as the banner becoming hidden and the intended page content appearing:
await page.click(acceptSelector);
await page.waitForFunction(selector => {
const element = document.querySelector(selector);
if (!element) return true;
const style = getComputedStyle(element);
const rect = element.getBoundingClientRect();
return style.display === 'none' ||
style.visibility === 'hidden' ||
Number(style.opacity) === 0 ||
rect.width === 0 || rect.height === 0;
}, {}, consentSelector);
await page.waitForSelector(readySelector, { visible: true });
await page.screenshot({ path: 'result.png' });
Replace consentSelector and readySelector with selectors that represent the actual page. If the element is removed from the DOM, a missing element counts as hidden in this example. If the site keeps a hidden duplicate or uses a shadow root, adapt the condition to inspect the visible consent UI rather than an unrelated matching node.
Finding and clicking the right control
- Inspect the live DOM after initial page load. Confirm the button label, selector, visibility, and whether more than one matching element exists.
- Check whether the consent manager is inside an iframe. A selector queried against the main page will not find a button in a separate frame; locate the appropriate frame and interact with its frame context.
- Check that the accept button is enabled and not covered by another element. Puppeteer’s
page.click(selector)throws when there is no matching element and clicks the first match if several match. See the [Page.click API](https://pptr.dev/api/puppeteer.page.click). - Use the site’s intended accept action. Do not remove the overlay from the DOM and treat that as acceptance: the site may not have recorded consent, and the page may still be blocked.
3. Verify the result before capturing
Check both sides of the expected state: the consent UI is gone and useful page content is visible. A screenshot call captures whatever state exists at that moment; it does not wait for a cookie banner to disappear by itself.
await page.waitForSelector(readySelector, { visible: true });
const banner = await page.$(consentSelector);
if (banner && await banner.isVisible()) {
throw new Error('Consent banner remains visible');
}
await page.screenshot({ path: 'result.png', fullPage: true });
For an element-only capture, first locate the desired element and then call element.screenshot(). For a full-page capture, page.screenshot({ fullPage: true }) asks Puppeteer to capture the full page. See the [Puppeteer screenshot guide](https://pptr.dev/guides/screenshots).
4. Check whether consent persists across reloads
If the banner disappears and then returns after reload, test persistence separately from the click. Puppeteer browser contexts isolate cookies and other storage; a new context is a fresh state. The consent manager may store its choice in a cookie, local storage, session storage, or another site-specific mechanism, so do not assume one cookie name or storage method.
Inspect cookies in the same browser context used for the page:
const context = page.browserContext();
const cookies = await context.cookies();
console.log(cookies.map(({ name, domain, expires }) => ({ name, domain, expires })));
To reuse a consented state, use a persistent browser profile or explicitly manage the site’s documented state. When setting cookies, use the browser or browser-context cookie methods. Puppeteer marks the page-level cookie API as deprecated; see its [cookie guide](https://pptr.dev/next/guides/cookies), [BrowserContext API](https://pptr.dev/api/puppeteer.browsercontext), and [Page cookies API](https://pptr.dev/api/puppeteer.page.cookies).
A fresh context is useful for testing whether your workflow actually records a new choice. A reused context can retain prior state and make a test appear to work even if the current click path is broken.
5. Troubleshooting common failures
| Symptom | Likely cause | Fix |
|---|---|---|
page.click() says no element matched |
The selector is wrong, the page has not rendered the banner, or the control is in an iframe. | Inspect the live DOM, wait for the actual selector, and query the correct frame if needed. |
| Screenshot happens before the banner disappears | The click promise completed before the app’s asynchronous update. | Wait for a site-specific hidden-banner or ready-content condition before capturing. |
| Navigation wait times out | The click dismisses the UI in place, or the chosen navigation event does not happen. | Use an in-page state wait when no navigation is expected. Do not mask unrelated errors with a broad catch. |
| Navigation occurs but the wait misses it | The script starts waiting only after awaiting the click. | Start waitForNavigation() and click() together with Promise.all. |
| Banner disappears but page remains blank or incomplete | The site is still rendering, a required request failed, or the readiness selector is too broad. | Wait for meaningful page content and inspect console errors, failed requests, and the page URL. |
| Banner returns after reload | Consent was not persisted, the site uses another storage mechanism, or the next page uses a different context or origin. | Inspect context cookies and site storage; confirm the same context and relevant origin are used. Follow the site’s actual consent behavior. |
| Click times out or hits the wrong control | Several buttons match, the button is hidden/disabled, or an overlay intercepts the click. | Use a unique selector for the visible enabled accept control and verify it in the live DOM before clicking. |
| Waiting for network idle never finishes | Long-lived connections or continuous background requests keep the network active. | Wait for a specific UI state or content selector instead of relying on network idle as proof of readiness. |
6. Reliability, speed, and cost considerations
- Prefer state-based waits. A selector or page condition tied to the expected outcome is usually more reliable than a fixed sleep. A short delay can hide a race on a fast run and fail on a slow one.
- Use bounded timeouts. Set realistic navigation and selector timeouts so a blocked page does not hang indefinitely. Log the URL, whether the click matched, and which condition timed out.
- Capture diagnostics on failure. Save a failure screenshot and record the current URL and visible banner state. Avoid logging sensitive page contents or authentication data.
- Reuse browser processes carefully. Reusing a launched browser can reduce repeated startup overhead in a worker, while separate contexts keep session state isolated. Close pages and contexts when work ends.
- Account for the page’s own load behavior. Third-party consent scripts, redirects, and client-rendering affect capture time. Waiting for network idle may cost time without proving the desired content is visible.
- Cost is operational. A self-hosted Puppeteer flow uses your compute, browser maintenance, and debugging time. The exact cost depends on your deployment and workload; there is no universal per-screenshot figure.
Or skip the browser setup
[ScreenshotNeo](https://screenshotneo.com) is a website screenshot API and MCP server. Its API accepts one GET request for a screenshot or PDF; see the [API documentation](https://screenshotneo.com/docs/) for options. For example, this cURL request saves a WebP capture:
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
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)
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}`);
ScreenshotNeo 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 account](https://screenshotneo.com/account/sign-up/) to get started.
FAQ
Does Puppeteer wait for the page to finish after page.click()?
It waits for the click operation, not for every application-specific effect. Wait separately for navigation when expected or for a meaningful post-click UI state.
Should I always use networkidle before a screenshot?
No. A page can keep making requests or hold connections open. Prefer a condition that proves the content you need is ready.
Can I use the same accept selector on every site?
No. Consent interfaces differ, and controls may live in frames or change with locale and page state. Inspect the target page.
Why did consent work in one run but not another?
Runs may use different browser contexts, storage state, navigation timing, or page content. Log the context and verify the post-click condition on each capture path.


