ScreenshotNeo

BlogHow-to

Fix Puppeteer Screenshots That Capture a Login Loading Screen

Diagnose why Puppeteer captures a login loading screen, verify session and redirect state, and wait for authentication before saving the screenshot.

By the ScreenshotNeo team4 October 20268 min read

A Puppeteer screenshot captures whatever the page shows at the moment page.screenshot() runs. A login loading screen does not, by itself, tell you why the page is still there. Check the final URL and navigation response, confirm that the browser context has the intended session, and wait for an application-specific authenticated state before capturing.

The most reliable readiness signal is usually a selector that appears only after successful login. If clicking the login button triggers a document navigation, register page.waitForNavigation() and the click together with Promise.all(), then wait for that authenticated selector. Puppeteer’s waitForSelector API waits for a matching element to appear.

1. Diagnose what Puppeteer actually loaded

Start by logging the URL and response returned by page.goto(). Puppeteer resolves that call with the response for the final destination after redirects, so a final login URL is useful evidence that the browser ended up on the login route. It does not establish whether the cause was an expired session, a missing cookie, an application error, or something else. Inspect the page and the site’s authentication flow to determine that.

const response = await page.goto(targetUrl, { waitUntil: 'domcontentloaded' });
console.log({
  requestedUrl: targetUrl,
  finalUrl: page.url(),
  status: response?.status(),
});

See the official Page.goto API for redirect behavior. A successful HTTP response alone also does not mean the application has finished loading or that a user is authenticated.

2. Check the browser context and session

Cookies and localStorage are scoped to a browser context. A fresh context is isolated from other contexts, so it will not automatically contain the login state established elsewhere. Reuse the context in which login completed, or deliberately restore the session data required by your own application before navigation.

Puppeteer provides cookie APIs to inspect, set, and delete cookies; use the current Puppeteer cookies guide for the version installed in your project. Do not assume cookies are the only authentication mechanism: the exact session storage and login flow are application-specific.

// Keep and reuse the context in which your app's login completed.
const context = await browser.createBrowserContext();
const page = await context.newPage();

// If your test deliberately restores a cookie, obtain its valid value
// through your approved test setup; do not hard-code production credentials.
await page.setCookie({
  name: 'session_cookie_name',
  value: process.env.TEST_SESSION_COOKIE,
  domain: 'example.com',
  path: '/',
  secure: true,
  httpOnly: true,
});

await page.goto('https://example.com/account', { waitUntil: 'domcontentloaded' });

Replace the cookie fields with the target site’s actual cookie requirements and use a test account or approved session fixture. Avoid printing session values to logs. Puppeteer documents context isolation in its BrowserContext API.

HTTP authentication is not a form login

page.authenticate() supplies credentials for HTTP authentication. It is not a general mechanism for submitting a website’s HTML login form. For a web form, interact with the actual form or use the application’s supported test authentication setup. See the Puppeteer authentication and cookies guidance.

3. Wait for login completion, not an arbitrary delay

Prefer a stable selector that is only present in the authenticated interface, such as an account menu or dashboard heading. Choose a selector from the target application; generic selectors copied from another site do not prove that this site’s login succeeded.

await page.waitForSelector('[data-testid="account-dashboard"]', {
  visible: true,
  timeout: 30_000,
});

await page.screenshot({ path: 'authenticated-page.png', fullPage: true });

You can also wait for a known loading indicator to disappear if that is a reliable signal in the application. Puppeteer selectors support visibility options; its page interactions guide also covers locators and waiting for element states.

When network idle helps

Puppeteer’s screenshot guide demonstrates navigation with waitUntil: 'networkidle2'. Use a network-idle condition when a quiet network is meaningful for the page, but do not treat it as proof of authentication: a login screen can also become network-idle, and some applications keep network connections open. Prefer the authenticated-state selector when one is available. See the Puppeteer screenshots guide.

4. Synchronize a login click that navigates

If clicking the login button triggers document navigation, install the navigation wait before the click can finish. Puppeteer’s documented pattern is to await both promises together. After navigation, still wait for the authenticated UI condition before taking the screenshot.

const [loginResponse] = await Promise.all([
  page.waitForNavigation({ waitUntil: 'domcontentloaded', timeout: 30_000 }),
  page.click('button[type="submit"]'),
]);

console.log({ finalUrl: page.url(), status: loginResponse?.status() });

await page.waitForSelector('[data-testid="account-dashboard"]', {
  visible: true,
  timeout: 30_000,
});

await page.screenshot({ path: 'authenticated-page.png', fullPage: true });

Use the real submit button and post-login selector for your page. Puppeteer’s Page API notes that History API URL changes count as navigation for navigation waits. However, a single-page application may update its authenticated UI without a document navigation. Observe the app’s behavior: if there is no navigation, omit waitForNavigation() and wait for the authenticated selector or state instead.

5. Complete runnable example

This Node.js example launches Puppeteer, opens a target page, reports the final navigation details, and captures only after the configured authenticated selector appears. Set the URL and selector for your application. Install Puppeteer in your project with npm install puppeteer, then save this as screenshot.js and run it with TARGET_URL=https://example.com/account AUTH_SELECTOR='[data-testid="account-dashboard"]' node screenshot.js.

const puppeteer = require('puppeteer');

async function main() {
  const targetUrl = process.env.TARGET_URL;
  const authSelector = process.env.AUTH_SELECTOR;
  if (!targetUrl || !authSelector) {
    throw new Error('Set TARGET_URL and AUTH_SELECTOR for your application.');
  }

  const browser = await puppeteer.launch({ headless: true });
  try {
    const page = await browser.newPage();
    page.setDefaultNavigationTimeout(30_000);

    const response = await page.goto(targetUrl, { waitUntil: 'domcontentloaded' });
    console.log({ requestedUrl: targetUrl, finalUrl: page.url(), status: response?.status() });

    await page.waitForSelector(authSelector, { visible: true, timeout: 30_000 });
    await page.screenshot({ path: 'authenticated-page.png', fullPage: true });
    console.log('Saved authenticated-page.png');
  } finally {
    await browser.close();
  }
}

main().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});

This example assumes the intended session is already available to the page, or that the target URL itself leads to an authenticated state. If the run must log in, add the site’s own form interactions and the navigation synchronization pattern above. Do not place real passwords or session tokens directly in source code.

6. Troubleshooting common failures

Symptom Likely explanation to investigate Next step
Final URL is the login route The navigation ended on a login page; the intended session may not be present or accepted. Log the final URL and response status, then inspect the context’s session setup and the site’s redirect flow.
Selector wait times out The selector is wrong, hidden, or the authenticated state was never reached. Inspect the rendered page and confirm the selector exists only after login. Increase the timeout only if the app legitimately needs more time.
Screenshot shows a spinner even after network idle Network quiet did not correspond to application readiness. Wait for a stable authenticated selector or for the app’s known loading indicator to disappear.
Login click returns but the page is still loading The click may have triggered navigation that was not synchronized, or an SPA may be updating in place. For document navigation, use Promise.all([page.waitForNavigation(), page.click(...)]). For an in-page update, wait for the resulting authenticated UI state.
Login works in one run but not in a new context Contexts isolate browser storage. Reuse the authenticated context or restore the required test session data in the new context.
page.authenticate() has no effect on the login form It is for HTTP authentication, not generic HTML form submission. Submit the site’s form or use a supported test-login mechanism.
Navigation wait times out although the app looks logged in The application may change state without document navigation. Remove that wait if the observed flow is in-page, and wait for the authenticated selector instead.

7. Performance, reliability, and cost

A short fixed sleep is easy to add but unreliable: it can waste time on fast runs and still be too short on slow ones. A selector wait ties completion to the result you need. Set a bounded timeout so a broken or unauthenticated run fails clearly instead of hanging indefinitely. Record the final URL and response status alongside errors to make intermittent redirects easier to diagnose.

Network idle may add waiting time and can be unsuitable for pages with continuous background requests. A page-specific readiness condition is usually both more direct and easier to interpret. Puppeteer itself does not determine the cost of your browser infrastructure; resource use depends on your runtime, concurrency, page complexity, and how long each browser stays open.

Or skip the browser setup

If you need a screenshot of a publicly accessible page rather than a session-authenticated view, ScreenshotNeo provides a one-request screenshot API. Its browser flow accepts cookie and consent banners like a visitor and removes more than 60 known consent platforms, newsletter popups, and chat widgets before capture; each cleanup step can be turned off. Bot checks and CAPTCHAs, blank pages, timeouts, failed loads, and cache hits are not billed, and responses include X-Page-Verdict and X-Billed headers. It also offers an MCP server with take_screenshot, get_page_info, and capture_pdf tools for AI agents.

For a session-protected page, an API request does not replace the target site’s login flow; this option is for pages the API can access without your private browser session. ScreenshotNeo’s free plan includes 1,000 screenshots per month with no card, and paid plans start at $5 for 3,000. Every feature is on every plan. See the ScreenshotNeo 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}`);
const fs = require('node:fs/promises');
await fs.writeFile('shot.webp', Buffer.from(await res.arrayBuffer()));

Create a free ScreenshotNeo account for 1,000 screenshots a month with no card.

Frequently asked questions

Does a successful HTTP status mean login succeeded?

No. Check the final URL and wait for an element that represents the authenticated application state.

Should I always use networkidle2 before a screenshot?

No. Use it when network quiet is a useful signal for that page. It does not establish that login succeeded.

Can Puppeteer reuse cookies across browser contexts?

Contexts are isolated. Reuse the context that holds the session or deliberately set the session data required by your test.

What if login updates the page without changing documents?

Skip the document navigation wait and wait for the authenticated UI condition that the application exposes.

Official Puppeteer references