ScreenshotNeo

BlogHow-to

Take Screenshots of a Private Web App with Playwright Storage State in Node.js

Reuse Playwright authentication state to capture private pages in Node.js, with secure setup, readiness checks, troubleshooting, and alternatives.

By the ScreenshotNeo team4 October 202610 min read

Use Playwright’s saved browser storage state to start a fresh browser context with an existing authenticated session, then navigate to the private route and call page.screenshot(). Save the state after a successful login, keep it out of version control, and wait for a meaningful page condition before capturing. A saved state can expire or be revoked, so your script should report login redirects and readiness failures instead of silently saving a login page.

What storage state does—and does not do

A storage-state file lets a new browser context start with previously saved authentication state. It does not perform login, guarantee that the session remains valid, or preserve every browser storage mechanism automatically. Playwright’s standard saved state covers cookies and local storage; session storage needs custom handling. Consult the Playwright authentication guide and BrowserContext storageState API reference for version-specific options.

Treat the file as a credential. Playwright warns that it may contain sensitive cookies and headers usable to impersonate your account. Keep it out of source control, restrict access, and regenerate it if it expires or is exposed.

1. Install Playwright and prepare a private state path

For a standalone Node.js script, install Playwright and its Chromium browser:

npm install playwright
npx playwright install chromium

Create a dedicated directory for authentication state and exclude it from Git:

mkdir -p playwright/.auth

Add this entry to your project’s .gitignore:

playwright/.auth/

Use a dedicated test account where possible. If multiple tests share an account and mutate the same server-side data, they can interfere with one another; Playwright recommends separate accounts per worker in that situation.

2. Log in once and save the state

This example uses the app’s login form. Replace the example host, selectors, and post-login destination with your application’s real values. It waits for an authenticated-page control before writing the file, so failed authentication is visible.

// save-auth.mjs
import { chromium } from 'playwright';

const authFile = 'playwright/.auth/user.json';
const username = process.env.APP_USERNAME;
const password = process.env.APP_PASSWORD;
if (!username || !password) {
  throw new Error('Set APP_USERNAME and APP_PASSWORD in the environment.');
}

const browser = await chromium.launch({ headless: true });
try {
  const context = await browser.newContext();
  const page = await context.newPage();
  await page.goto('https://app.example.com/login', { waitUntil: 'domcontentloaded' });
  await page.getByLabel('Email').fill(username);
  await page.getByLabel('Password').fill(password);
  await page.getByRole('button', { name: 'Sign in' }).click();

  // Use a stable control that only appears after successful authentication.
  await page.getByRole('navigation', { name: 'Main' }).waitFor({ state: 'visible', timeout: 15000 });
  await context.storageState({ path: authFile });
  console.log(`Saved authenticated state to ${authFile}`);
  await context.close();
} finally {
  await browser.close();
}

Run it with credentials supplied by your shell or secret manager, rather than embedding them in the script:

APP_USERNAME='you@example.com' APP_PASSWORD='your-secret' node save-auth.mjs

If the application supports API login, you can use that instead of driving the login UI, provided the login flow establishes browser storage in a way Playwright can save. In either case, wait for a confirmed authenticated condition before calling storageState(). Avoid printing cookies, tokens, or the state file contents to logs.

3. Load the state and capture the private page

Create a new context with storageState, navigate to the desired route, verify that the app is authenticated, wait for page-specific content, then write the image. This standalone script checks both the final URL and a page heading, which helps catch expired sessions and unexpected redirects.

// capture-private-page.mjs
import { chromium } from 'playwright';

const authFile = 'playwright/.auth/user.json';
const targetUrl = 'https://app.example.com/reports/monthly';
const outputPath = 'screenshot.png';

const browser = await chromium.launch({ headless: true });
try {
  const context = await browser.newContext({ storageState: authFile });
  const page = await context.newPage();
  await page.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout: 30000 });

  if (new URL(page.url()).pathname.startsWith('/login')) {
    throw new Error('Authentication state is invalid or expired: redirected to login. Run save-auth.mjs again.');
  }

  await page.getByRole('heading', { name: 'Monthly report' }).waitFor({ state: 'visible', timeout: 15000 });
  await page.screenshot({ path: outputPath, fullPage: true });
  console.log(`Saved ${outputPath} from ${page.url()}`);
  await context.close();
} finally {
  await browser.close();
}

Run the capture:

node capture-private-page.mjs

The heading and login path above are examples. Choose a stable selector or final URL that proves the specific page loaded with the expected account. If the page renders critical data asynchronously, wait for that data’s visible state too.

4. Choose the right readiness condition

Navigation completing does not necessarily mean the private page is ready. Prefer a condition tied to the expected content or final route, such as a heading, table row, account control, or URL. A fixed delay is less reliable because network and rendering times vary.

  • Wait for a visible control: await page.getByRole('heading', { name: 'Monthly report' }).waitFor().
  • Wait for a route: after clicking a control, use await page.waitForURL('**/reports/monthly').
  • Wait for a specific response or application signal: useful when the page exposes a stable request or readiness marker.
  • Use a bounded timeout: a timeout should fail the capture clearly rather than produce a misleading image.

Playwright supports navigation wait conditions through its Page and test configuration APIs. Avoid treating networkidle as proof that every application is visually ready; pages with polling, analytics, or long-lived connections may never become idle. Wait for the content your screenshot is meant to show.

5. Screenshot options and useful variations

page.screenshot() captures the current page as an image. The most useful options for this workflow are:

Option Effect When to use it
path Writes the image to a file; the extension determines the format. Saving an artifact such as screenshot.png.
fullPage Captures the full scrollable page. Long reports or settings pages; check whether sticky elements repeat or content loads on scroll.
type Selects png or jpeg when not inferred from the path. When output format must be explicit.
quality Sets JPEG quality; applies to JPEG output. Smaller JPEG artifacts where some loss is acceptable.
omitBackground Allows transparency for supported image output. When a transparent page background is intended.
clip Captures a specified rectangle. A known viewport region; the clip must fit the page.

For a viewport-only JPEG:

await page.screenshot({ path: 'report.jpg', type: 'jpeg', quality: 85 });

For a screenshot of one element rather than the whole page:

await page.getByTestId('report-chart').screenshot({ path: 'chart.png' });

For consistent dimensions, configure the context’s viewport when creating it:

const context = await browser.newContext({
  storageState: authFile,
  viewport: { width: 1440, height: 1000 },
  deviceScaleFactor: 1
});

Review the Page screenshot API for the complete option set and current behavior.

Session storage and other authentication mechanisms

Standard Playwright storage state does not include sessionStorage. If your app stores authentication there, save and restore it explicitly. One approach is to read the relevant session storage values after login, serialize them to a protected file, and install them with page.addInitScript() before navigating to the app origin. Restrict the script to the correct origin and only the required keys; do not copy unrelated session data into artifacts.

Storage support can depend on the installed Playwright version. The BrowserContext API reference marks IndexedDB storage-state support as added in v1.51, credentials in v1.61, and OPFS in v1.63. Check the API reference and your installed version before relying on these options. Pass the documented options to context.storageState() only when your version supports them.

Passkeys or other WebAuthn flows may require their own setup rather than replaying cookies. Confirm that the state mechanism your app uses is represented in the saved state and can be restored in the browser context used by the script.

Using storage state with Playwright Test

For a repeatable test suite, Playwright’s authentication guide describes a setup-project pattern that authenticates before dependent tests and a worker-scoped pattern for separate account state. A simple test can load a saved state from configuration and capture an artifact:

// playwright.config.mjs (illustrative project configuration)
import { defineConfig } from '@playwright/test';

export default defineConfig({
  use: {
    baseURL: 'https://app.example.com',
    storageState: 'playwright/.auth/user.json'
  }
});
// tests/report.spec.mjs
import { test, expect } from '@playwright/test';

test('capture the monthly report', async ({ page }, testInfo) => {
  await page.goto('/reports/monthly');
  await expect(page.getByRole('heading', { name: 'Monthly report' })).toBeVisible();
  await page.screenshot({ path: testInfo.outputPath('monthly-report.png'), fullPage: true });
});

Use page.screenshot() when you need an image artifact. Use expect(page).toHaveScreenshot() when the goal is a visual-regression assertion: Playwright Test waits for consecutive captures to stabilize before comparison. See the authentication guide and visual comparisons guide for the setup-project and worker-scoped patterns.

Or skip the browser setup

If you have a page that can be captured by URL, ScreenshotNeo can return an image or PDF from one request. Its API supports 63 options, including full-page capture, element selectors, custom waits, headers, cookies, and user agent configuration. 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}`);
await Bun.write('shot.webp', res);

For authenticated pages, provide the cookies or headers your application accepts using the API’s documented options, and keep API keys and session credentials on the server side. ScreenshotNeo accepts cookie and consent banners like a visitor and removes more than 60 known consent platforms, newsletter popups, and chat widgets before capture; those steps can be turned off. Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed, and response headers report the page verdict and billing status. Its MCP server provides take_screenshot, get_page_info, and capture_pdf tools for AI agents. The free plan includes 1,000 shots a month with no card; paid plans start at $5 for 3,000 shots.

Sign up for 1,000 free screenshots a month, with no card required.

Performance, reliability, and cost

  • Reuse authentication, not a live browser: logging in once and loading state for captures avoids repeating the login flow for each image. Refresh state when the server invalidates it.
  • Keep captures deterministic: use a fixed viewport, stable page condition, and consistent test data. Dynamic timestamps, animations, and live data can make image comparisons vary.
  • Control page size: full-page captures of very long pages produce larger images and may expose lazy-loaded content behavior. Wait for the relevant content and verify the resulting capture.
  • Close resources: use finally to close the browser even when navigation or screenshot capture fails.
  • Protect credentials: the state file is sensitive. In CI, store it as a protected secret artifact with limited access, or generate it during the job and remove it afterward.
  • Plan for account contention: shared state can be convenient for read-only captures, but parallel workers should not mutate shared account data. Use isolated accounts or worker-specific state where needed.
  • Local cost: Playwright is open-source browser automation software, but running it still uses machine or CI compute, browser installation, storage, and maintenance. No fixed execution cost applies across environments.

Troubleshooting

Symptom Likely cause Fix
Capture shows the login page State expired, was saved before login completed, or belongs to another origin/account. Repeat login, wait for an authenticated control, save state again, and check the final URL after navigation.
Missing cookies or local storage State was saved too early or the app writes auth asynchronously. Wait for a confirmed authenticated page condition before calling storageState(); verify the correct origin.
Session works manually but not from the state file The app relies on sessionStorage or a mechanism not included in standard state. Implement explicit sessionStorage save/restore, or configure and verify the relevant version-dependent storage support.
Timeout waiting for content Selector changed, data failed to load, or the app is slower than the timeout. Check the selector and app response, wait on a stable semantic condition, and adjust the timeout only if the slower load is expected.
Full-page image omits content Content is lazy-loaded only when scrolled into view, or the page has a virtualized list. Scroll through the needed content before capture or use the app’s export route; virtualized lists may require a different capture strategy.
Browser executable missing The Playwright package is installed but its browser was not installed in the environment. Run npx playwright install chromium in the environment that executes the script.
Parallel screenshots affect each other Tests share an account that has mutable server-side state. Use separate accounts per worker for tests that change shared data, as described in Playwright’s authentication guidance.

FAQ

Can I use one state file for multiple private routes?

Yes, if those routes share the same valid account session and origin. Load the same file into a context and navigate to each route; handle route-specific readiness and authorization separately.

Does saving state save the page itself?

No. It saves browser authentication state supported by the API. The script still navigates to the route and renders the page for each capture.

Should I use a screenshot assertion instead?

Use page.screenshot() to create an image file. Use toHaveScreenshot() when you want Playwright Test to compare the rendered page against a visual baseline.

Can I safely commit the state file for convenience?

No. It may contain credentials that impersonate the account. Keep it ignored, restrict access, and rotate the session if it is accidentally shared.