ScreenshotNeo

BlogHow-to

Fix a Blank Website Screenshot After Login with Puppeteer

A blank post-login screenshot usually means Puppeteer captured before the authenticated page was ready. Choose a wait based on the site’s actual login transition, then capture and debug the result.

By the ScreenshotNeo team4 October 20269 min read

A blank screenshot after login usually means the capture ran before the authenticated page reached its ready state. A resolved click does not prove that login succeeded or that the page finished rendering. First determine whether the login action causes a document navigation or a client-side update; then wait for that transition and a meaningful post-login condition before calling page.screenshot().

There is no universal fix without knowing the site, login provider, redirect sequence, framework, and browser output. The selectors below are examples: replace them with landmarks from the application you are capturing.

1. Identify what happens after login

  1. Record page.url() before submitting credentials, immediately after the action, and just before the screenshot.
  2. Check whether the submit action loads a new document, changes the URL through client-side routing, or updates the page without changing the URL.
  3. Choose a readiness condition that represents the authenticated content you actually want in the image: a visible account heading, dashboard landmark, or application-specific predicate.
  4. Capture only after that condition succeeds. If it never appears, diagnose the failure instead of saving a premature blank image.

Puppeteer documents Page.screenshot() as the capture method, separately from navigation and readiness waits. Its [screenshot guide](https://pptr.dev/guides/screenshots) demonstrates navigation followed by capture. The [Page API](https://pptr.dev/api/puppeteer.page) documents navigation, selector, function, and network-idle waits.

2. Runnable Puppeteer example for a login that navigates

Install Puppeteer with npm install puppeteer. Set LOGIN_URL, LOGIN_USER, and LOGIN_PASSWORD in the environment, then save this as capture.mjs and run node capture.mjs. Adapt the form selectors and authenticated-page selector to the target site.

import puppeteer from 'puppeteer';

const loginUrl = process.env.LOGIN_URL;
const username = process.env.LOGIN_USER;
const password = process.env.LOGIN_PASSWORD;
if (!loginUrl || !username || !password) {
  throw new Error('Set LOGIN_URL, LOGIN_USER, and LOGIN_PASSWORD');
}

const browser = await puppeteer.launch({ headless: true });
try {
  const page = await browser.newPage();
  page.setDefaultNavigationTimeout(30_000);
  page.setDefaultTimeout(15_000);
  page.on('console', msg => console.log('PAGE LOG:', msg.text()));
  page.on('pageerror', error => console.error('PAGE ERROR:', error.message));
  page.on('requestfailed', request => {
    console.error('REQUEST FAILED:', request.url(), request.failure()?.errorText);
  });

  await page.goto(loginUrl, { waitUntil: 'domcontentloaded' });
  console.log('Before login:', page.url());
  await page.locator('input[name="username"]').fill(username);
  await page.locator('input[name="password"]').fill(password);

  // Register the navigation wait before the click to avoid a race.
  const [response] = await Promise.all([
    page.waitForNavigation({ waitUntil: 'domcontentloaded', timeout: 30_000 }),
    page.locator('button[type="submit"]').click(),
  ]);
  console.log('Navigation response:', response?.status(), 'URL:', page.url());

  // A successful navigation does not itself prove the app is ready.
  await page.waitForSelector('[data-testid="account-home"]', {
    visible: true,
    timeout: 20_000,
  });
  console.log('Ready URL:', page.url());
  await page.screenshot({ path: 'after-login.png', fullPage: true });
} finally {
  await browser.close();
}

The navigation wait and click run together because waiting only after clicking can miss a fast navigation. This is Puppeteer’s documented pattern. See [waitForNavigation() and related Page methods](https://pptr.dev/api/puppeteer.page).

3. Client-side login: wait for the authenticated UI

Some apps update state in the current document or use client-side routing. A waitForNavigation() that expects a new document can then time out or fail to express the condition you need. Click the login control and wait for a visible post-login landmark instead.

// Use this pattern when the app updates without a document navigation.
await page.locator('input[name="username"]').fill(username);
await page.locator('input[name="password"]').fill(password);
await page.locator('button[type="submit"]').click();

await page.waitForSelector('[data-testid="account-home"]', {
  visible: true,
  timeout: 20_000,
});
console.log('Authenticated view URL:', page.url());
await page.screenshot({ path: 'after-login.png', fullPage: true });

When no stable selector exists, wait for an application-specific condition. Keep the predicate tied to authenticated content rather than an arbitrary delay.

await page.waitForFunction(() => {
  const heading = document.querySelector('h1');
  return heading?.textContent?.trim() === 'Account overview';
}, { timeout: 20_000 });
await page.screenshot({ path: 'after-login.png', fullPage: true });

If login changes the URL using the History API, Puppeteer documents that URL change as navigation. Still verify the page’s meaningful ready state before capturing; a changed URL alone may not mean the rendered content is ready.

4. Choose the right wait condition

Observed behavior Wait strategy What it establishes
Submit causes a new document or reload Promise.all([page.waitForNavigation(...), click]) The navigation completed according to the selected lifecycle event.
Client-side route or UI update waitForSelector() for a visible authenticated landmark The selected element exists and is visible.
App-specific condition without a stable element waitForFunction() The supplied browser-side predicate became truthy.
Need to wait for network activity to settle waitForNetworkIdle() or a navigation lifecycle such as networkidle2 Network activity met that API’s idle condition; it does not prove the correct UI is visible.

DOMContentLoaded and load are document lifecycle events; networkidle0 and networkidle2 are network-idle choices. Use them as useful signals, not as universal proof of authentication or rendering. Applications with long-lived requests may not become idle. An authenticated selector or predicate is usually the clearest capture condition when the app exposes one.

For example, to wait for navigation and use a lifecycle choice:

const [response] = await Promise.all([
  page.waitForNavigation({ waitUntil: 'networkidle2', timeout: 30_000 }),
  page.locator('button[type="submit"]').click(),
]);
await page.waitForSelector('[data-testid="account-home"]', { visible: true });
await page.screenshot({ path: 'after-login.png' });

For apps where a separate network-idle wait is useful, use it alongside the app condition, not instead of it:

await page.waitForNetworkIdle({ timeout: 15_000 }).catch(error => {
  console.warn('Network did not become idle:', error.message);
});
await page.waitForSelector('[data-testid="account-home"]', { visible: true });
await page.screenshot({ path: 'after-login.png' });

Whether to treat a network-idle timeout as fatal depends on the site. If the authenticated landmark appears and the app intentionally keeps connections open, the landmark can be the relevant condition. If the landmark never appears, do not hide that failure behind a screenshot call.

5. Diagnose a page that remains blank

  1. Check the URL at each stage. Log it before login, after the click or redirect, and immediately before capture. A URL stuck on the login page can indicate that the expected transition did not happen.
  2. Check the HTTP response when there was navigation. Log the response status. A completed navigation is not necessarily a successful login.
  3. Listen for browser-side errors. Attach console, pageerror, and requestfailed listeners before submitting. This distinguishes page JavaScript and failed requests from Node.js script errors.
  4. Run headed to observe the browser. Launch with headless: false; optionally add slowMo: 100 to slow operations. Inspect the page with DevTools while reproducing the flow.
  5. Only escalate logs if needed. Puppeteer’s debugging guide covers Node-side, browser-side, and browser-internal failure areas, along with protocol and browser logging. See [Puppeteer debugging](https://pptr.dev/guides/debugging).
const browser = await puppeteer.launch({
  headless: false,
  slowMo: 100,
});

Keep credentials out of source control and logs. Use environment variables or an appropriate secret store for the environment running the script. Never print password field values or authentication tokens while debugging.

6. Common errors and fixes

Symptom Likely cause Fix
waitForNavigation() times out The app did not navigate, or the selected lifecycle condition was never reached. Confirm the transition. For a client-side update, wait for the authenticated selector or predicate instead.
Screenshot is blank but the script succeeds The capture ran before useful content rendered, or the app rendered an empty/error state. Wait for a real post-login landmark, log URL and browser errors, and inspect headed mode.
waitForSelector() times out The selector is wrong, the login failed, or the expected content never became visible. Verify the selector against the actual page and check redirect, console, and request output. Treat the timeout as a readiness failure.
Network-idle wait never settles The app continues network activity or the chosen idle condition does not fit its behavior. Use a site-specific visible landmark as the primary ready condition; use network idle only when it is meaningful for the app.
Navigation wait catches the wrong event or misses a fast one The wait was registered after the click or the click triggers multiple transitions. Start the wait and click together with Promise.all; inspect the URL and response to identify the relevant transition.
Browser console or request errors appear Client-side application code or a network request is failing. Use the error details and DevTools to locate the failing script or request; the screenshot call cannot repair application failures.

7. Capture options, performance, and reliability

Once the page is ready, choose screenshot options for the output you need. Puppeteer’s [screenshot guide](https://pptr.dev/guides/screenshots) covers page screenshots and element screenshots; element capture scrolls the target into view by default.

// Viewport screenshot
await page.screenshot({ path: 'viewport.png' });

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

// Screenshot one element after it is visible
const card = await page.waitForSelector('[data-testid="account-card"]', { visible: true });
await card.screenshot({ path: 'account-card.png' });
  • Performance: Wait for the narrowest condition that accurately means “ready.” Full-page capture and waiting for broad network idleness can take longer than a viewport capture and a specific landmark. Avoid stacking arbitrary long delays on top of meaningful waits.
  • Reliability: Set explicit timeouts, log the URL and failure signals, and fail clearly when the authenticated marker never appears. Use the same login flow and selectors in the environment where captures run; a local browser display and a CI browser can surface different environmental issues.
  • Cost: Self-hosted Puppeteer does not have a per-screenshot ScreenshotNeo API charge, but the runtime still consumes your compute and maintenance time. For a hosted capture API, check its current plan and billing rules directly rather than assuming they match Puppeteer.
  • Authentication: Protect stored browser profiles, cookies, and screenshots as sensitive data. Use test accounts when possible and avoid exposing session material in artifacts.

Or skip the browser setup

ScreenshotNeo is a website screenshot API and MCP server from [ScreenshotNeo](https://screenshotneo.com). One GET request returns a PNG, JPEG, WebP, or PDF. The API is not a Puppeteer login-flow debugger: use it when the target page can be captured from a URL without a login step that your script must perform.

See the [ScreenshotNeo API docs](https://screenshotneo.com/docs/) for request options. This cURL example saves a WebP screenshot of a public page:

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,
)
r.raise_for_status()
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 Bun.write('shot.webp', res);

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 cleanup step can be turned off. Bot checks and CAPTCHAs, blank pages, timeouts, failed loads, and cache hits cost nothing, and response headers say which page verdict and billing outcome applied. Its MCP server provides take_screenshot, get_page_info, and capture_pdf for Claude, Cursor, and other MCP clients. The free plan includes 1,000 shots a month with no card; paid plans start at $5 for 3,000 shots. [Create a free ScreenshotNeo account](https://screenshotneo.com/account/sign-up/).

FAQ

Does a successful login click mean the user is authenticated?

No. Confirm a redirect, authenticated-page landmark, or application-specific state before capture.

Should I always use networkidle2?

No. It is a documented wait choice, but network idleness alone does not establish that the intended authenticated content is visible.

Can ScreenshotNeo capture a page behind my login?

The supplied product information describes URL-based captures and custom headers, cookies, user agents, and Authorization options. Whether those are sufficient depends on the site’s authentication flow; this guide does not assume every interactive login can be reproduced by a single API request.