Why Does Headless Chrome Screenshot an Empty Web App After Login?
An empty screenshot after login can mean the browser captured the wrong route, lost session state, or took the shot before the app rendered. Follow the evidence to find the cause.
An empty screenshot after login is a symptom, not a diagnosis. Headless Chrome may have captured the wrong route or unauthenticated state, taken the screenshot before the app rendered its authenticated content, or captured the wrong part of the page. First record the final URL and inspect the DOM, app state, console, and screenshot bounds. Change launch flags only when evidence points to a rendering or environment problem.
This guide uses Puppeteer for runnable examples and includes equivalent Playwright steps. It applies to the common questions “Why is my Puppeteer screenshot blank after login?” and “Why does my Playwright screenshot show a blank page after authentication?”
1. Identify what Chrome actually captured
A successful click or a login success message does not prove that the browser reached the authenticated app route. Redirects may lead back to login, to an error page, or to an unexpected route. Log the final URL, title, and presence of an authenticated app element immediately before capture.
// Puppeteer: inspect the page immediately before taking a screenshot.
console.log({
url: page.url(),
title: await page.title(),
appRootPresent: await page.$('[data-testid="app-root"]') !== null
});
Replace [data-testid="app-root"] with a selector that exists only in the signed-in view. If the URL is wrong, trace the login redirects and verify that the test is using the same browser context and page where login completed.
For a headless browser process you can attach Chrome DevTools to inspect the actual target. Chrome documents how to connect to a headless browser’s remote debugging endpoint in its Headless mode documentation.
2. Verify the session and cookie scope
Check that authentication state survives navigation to the app route. If the login happens in one browser context and the screenshot in another, the second context may have no session. When setting cookies manually, use the site’s real HTTP or HTTPS URL. A cookie cannot target about:blank; Puppeteer’s troubleshooting guidance recommends setting the cookie against the site URL instead (Puppeteer troubleshooting).
Do not assume a cookie problem without evidence. A final URL at the login screen, a missing authenticated root, or a request that returns an unauthenticated response are useful clues. If those checks pass, move on to app readiness and runtime errors.
3. Wait for the authenticated app to render
Page navigation completing is not necessarily the same as a single-page app finishing its data requests and rendering the signed-in view. Wait for an app-specific element that appears only when the content is ready. Prefer a readiness condition over a fixed delay.
// Puppeteer: navigate, authenticate, then wait for app-specific readiness.
await page.goto('https://app.example.com/login', { waitUntil: 'domcontentloaded' });
// Perform your app's login steps here, using the same page and browser context.
await page.waitForSelector('[data-testid="app-root"]', { timeout: 15000 });
await page.waitForSelector('[data-testid="dashboard-content"]', { timeout: 15000 });
console.log('Ready to capture:', page.url());
await page.screenshot({ path: 'dashboard.png', fullPage: true });
The example’s selectors and login steps are placeholders: use selectors and authentication actions for your app. The second wait is useful when the app shell appears before its data-backed content.
With Playwright, wait for the same app-specific condition rather than relying only on a navigation event:
// Playwright: use the same page and context through login and capture.
await page.goto('https://app.example.com/login');
// Perform your app's login steps here.
await page.locator('[data-testid="app-root"]').waitFor({ state: 'visible', timeout: 15000 });
await page.locator('[data-testid="dashboard-content"]').waitFor({ state: 'visible', timeout: 15000 });
console.log({ url: page.url(), title: await page.title() });
await page.screenshot({ path: 'dashboard.png', fullPage: true });
A short fixed sleep can help confirm that timing is involved, but it is a poor long-term readiness check: it can waste time on fast runs and still be too short on slow ones. Chrome’s command-line --timeout option delays capture, but a delay alone does not prove the application is ready (Chrome Headless documentation).
4. Inspect the DOM, console, and page errors
Before changing rendering settings, determine whether the expected content exists in the document and whether scripts failed. Chrome’s --dump-dom prints the DOM after page scripts have run, which can help distinguish an app that did not render from a screenshot that failed to show rendered content (Chrome Headless documentation).
// Puppeteer: collect browser console messages and uncaught page errors.
page.on('console', message => {
console.log(`CONSOLE ${message.type()}: ${message.text()}`);
});
page.on('pageerror', error => {
console.error('PAGE ERROR:', error);
});
Register these listeners before the app loads, then reproduce the login flow. In Playwright, equivalent page events can be captured with page.on('console', ...) and page.on('pageerror', ...).
If the app root or expected content is absent from the DOM, investigate route, session, script loading, data requests, and runtime errors. If the DOM contains the content but the screenshot does not, inspect visibility styles, viewport dimensions, screenshot clipping, and environment differences.
5. Check viewport, screenshot bounds, and environment
Set the viewport explicitly and ensure any screenshot clip includes the content. A narrow viewport can activate a different responsive layout; a mistaken clip can capture a blank region even when the app rendered elsewhere. Chrome’s headless documentation shows explicit --window-size use for command-line screenshots (Chrome Headless documentation).
// Puppeteer: make viewport and screenshot bounds explicit.
await page.setViewport({ width: 1440, height: 1000, deviceScaleFactor: 1 });
await page.screenshot({
path: 'dashboard.png',
fullPage: true
});
If the same app state renders differently in headful and headless runs, compare the browser version, operating system or container, fonts, settings, hardware, viewport, and headless mode. Playwright notes that visual rendering can vary with the host OS, browser version, settings, hardware, power source, and headless mode (Playwright visual comparisons). Investigate GPU or WebGL only if the app uses those features or other evidence points there; blank screenshots alone do not establish a GPU cause.
6. Use the observation to choose the next check
| Before capture, you observe | Check next |
|---|---|
| Final URL is login, error, or another unexpected route | Trace redirects, the target URL, and whether the authenticated session persists. |
| Expected authenticated root is absent from the DOM | Check context/session continuity, app scripts, data requests, and readiness timing. |
| App root exists, but expected content does not | Wait for the data-backed content condition and inspect runtime and network failures. |
| DOM contains visible content, but image is blank | Check CSS visibility, viewport, clip bounds, and rendering environment. |
| Headful works but headless does not | Compare browser and host environment; inspect the headless target through DevTools. |
These are diagnostic branches, not a claim that any one cause applies to every app. Follow the first observable discrepancy rather than applying all possible remedies.
7. Troubleshoot common failures
| Symptom or error | Likely explanation | Fix |
|---|---|---|
| Screenshot shows the login form | Redirect or authentication did not leave the page in the expected signed-in state. | Log page.url(), confirm the session in the same context, and assert the authenticated root before capture. |
| Wait for selector times out | The selector is wrong, the route/session is wrong, or app content has not rendered. | Inspect the URL and DOM; verify the selector in the signed-in view and check console and page errors. |
| App shell appears, data area is empty | The shell mounted before the app’s data-backed view became ready, or a request/runtime error prevented it. | Wait for a content-specific selector and inspect the app’s errors and data loading state. |
| DOM looks correct, screenshot is blank | Content may be hidden, outside the viewport or clip, or rendered differently in this environment. | Check computed visibility, remove incorrect clip settings, set the viewport explicitly, and compare environments. |
| Manually injected cookie has no effect | Cookie may be scoped to the wrong site or to about:blank. |
Set the cookie for the real HTTP(S) site and verify it is present in the same context used to navigate. |
| Adding a delay sometimes helps | Capture timing may race app rendering, but fixed delays vary with machine and network conditions. | Replace the sleep with an app-specific visible selector or explicit readiness signal. |
| Headful and headless outputs differ | Browser version, host environment, settings, hardware, or viewport differs. | Align those conditions and inspect the headless page before attributing the difference to a specific rendering feature. |
8. Collect evidence before changing flags
- Automation library and version; Chrome version and headless mode.
- Final URL, page title, and main document response status.
- Whether the authenticated root and expected content selectors exist immediately before capture.
- A small DOM excerpt around the app root, plus console messages and page errors.
- Screenshot dimensions, clip settings, viewport, and device scale factor.
- Operating system or container details, and whether the same account and flow render in a controlled visible-browser comparison.
This evidence separates route or session problems from readiness, runtime, and capture geometry issues. It also makes a headful/headless comparison meaningful.
9. Or skip the browser setup
For a public page, ScreenshotNeo can return a screenshot with one GET request, without maintaining your own browser capture setup. It is a website screenshot API and MCP server by Yorker Media. See the ScreenshotNeo API documentation for options and integration details.
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}`);
ScreenshotNeo accepts cookie and consent banners like a visitor and removes more than 60 known consent platforms, newsletter popups, and chat widgets before the shot; each of those steps can be turned off. Bot checks and CAPTCHAs, blank pages, timeouts, failed loads, and cache hits cost nothing, and response headers report the page verdict and billing status. Its MCP server gives AI agents tools to take screenshots, get page information, and capture PDFs. The free plan includes 1,000 screenshots per month with no card; paid plans start at $5 for 3,000 shots. These captures are for pages the API can access; use your own authenticated browser context when the target depends on a private login session.
Sign up free for 1,000 screenshots a month, with no card.
10. Performance, reliability, and cost considerations
For a browser-based workflow, reuse the same page and context through login and capture, set a sensible timeout, and wait for the specific content needed rather than sleeping for an arbitrary interval. Full-page screenshots can take longer and produce larger files than viewport captures. A readiness selector improves repeatability, while a fixed delay adds time to every run and still cannot guarantee the page is ready.
When diagnosing an intermittent issue, retain enough logs to associate the screenshot with its URL, readiness checks, and browser errors. Compare runs under the same browser and host conditions before treating visual differences as an app defect. The supplied research provides no benchmark for capture speed or cost across local browser setups, so measure your own workload.
ScreenshotNeo’s listed monthly plans are Free: 1,000 shots, Starter: $5 for 3,000, Growth: $15 for 15,000, Pro: $39 for 60,000, Scale: $99 for 250,000, and Business: $249 for 1,000,000; yearly billing gives two months free. Every feature is available on every plan. Its billed-shot rules mean failed or empty captures and cache hits do not consume paid shots.
FAQ
Does a successful login click mean the app is ready to screenshot?
No. Confirm the final URL and wait for an authenticated, content-specific app condition before capturing.
Should I add --no-sandbox or change GPU flags?
An empty screenshot alone does not identify a sandbox or GPU issue. Inspect the URL, DOM, runtime errors, viewport, and environment first; change launch settings only when evidence supports it.
Is waiting for network idle enough?
Not necessarily. An app can continue background requests after it is usable, or render its important content after a navigation milestone. Prefer an app-specific readiness condition.
Can ScreenshotNeo capture my post-login dashboard?
The one-call example is for a publicly accessible URL. If access depends on an existing private browser session, use automation in that authenticated context; ScreenshotNeo supports custom headers and cookies, but configure only credentials and access you are authorized to use.


