How to Capture a View Before It Renders
Capture a browser’s loading or transitional state by waiting for the exact lifecycle milestone or UI condition you need, then screenshot immediately.
To capture a view before it finishes rendering, start the navigation or UI action and take the screenshot as soon as the intended intermediate state is true. Choose a browser lifecycle milestone such as commit or domcontentloaded when that milestone defines the state. If the state is application-specific, wait for a loading overlay, selector, assertion, or other observable condition. Do not wait for load or networkidle unless those events are the state you actually want.
A screenshot records pixels the browser has rendered at capture time. It cannot include content that has not rendered yet. The practical problem is therefore timing: start navigation, wait for the earliest condition that identifies your target view, and capture immediately.
What “before it renders” can mean
The phrase usually refers to one of four states:
- Initial document: the response has arrived and document loading has started, but most resources and application code have not completed.
- DOM loaded: the HTML has been parsed, while images, stylesheets, fonts, and asynchronous application work may still be running.
- Loading UI: a spinner, skeleton, progress bar, or blocking overlay is visibly present.
- Partially populated application: some data is visible while other components are still fetching or hydrating.
These states require different waits. Playwright documents commit, domcontentloaded, load, and networkidle as different navigation milestones. Its documentation defines networkidle as no network connections for at least 500 ms and discourages using it for tests; a web assertion tied to the desired state is more reliable. See the Playwright navigation documentation.
Choose the capture condition first
| Desired image | Condition to use | Typical capture scope |
|---|---|---|
| Earliest browser view | waitUntil: 'commit' |
Viewport |
| Parsed HTML before subresources finish | domcontentloaded |
Viewport or element |
| Visible loading overlay | Wait for the overlay to be visible | Viewport or overlay element |
| Skeleton or partial data state | Assert a skeleton or target component is visible | Viewport or component |
| Stable regression baseline | Wait for the application’s ready assertion, then use a stable screenshot comparison | Viewport, full page, or element |
Capture scope changes the question your image answers. A viewport screenshot captures what a user can currently see. A full-page screenshot captures the document’s scrollable page, which may require additional layout and lazy-loading work. An element screenshot isolates one component. Playwright documents page and full-page screenshots in its screenshot guide; Puppeteer documents page and element screenshots in its screenshot guide.
Playwright: capture at a navigation milestone
Install Playwright and a browser:
npm install -D playwright
npx playwright install chromium
Capture immediately after the response commits
import { chromium } from 'playwright';
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
await page.goto('https://example.com', {
waitUntil: 'commit',
timeout: 30_000
});
await page.screenshot({ path: 'before-load.webp', type: 'webp' });
await browser.close();
commit is the earliest documented navigation milestone: the response has been received and document loading starts. The screenshot may contain a blank page or only early markup if the site has not painted visible content yet. That is expected when you intentionally capture this stage.
Capture after the DOM is parsed, before the page fully loads
import { chromium } from 'playwright';
const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto('https://example.com', {
waitUntil: 'domcontentloaded',
timeout: 30_000
});
await page.screenshot({ path: 'dom-loaded.png' });
await browser.close();
This is useful when the initial HTML contains the loading screen or server-rendered shell, but images, fonts, and client-side requests are still in progress.
Capture a visible loading indicator
import { chromium } from 'playwright';
const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto('https://example.com/dashboard', {
waitUntil: 'commit',
timeout: 30_000
});
const spinner = page.locator('[data-testid="loading-spinner"]');
await spinner.waitFor({ state: 'visible', timeout: 10_000 });
await page.screenshot({ path: 'loading-state.png' });
await browser.close();
Waiting for the condition itself is safer than sleeping for a guessed number of milliseconds. If the indicator is rendered synchronously, the assertion can resolve immediately; if the server is slower, the finite timeout gives you a useful failure.
Start an action and capture its transitional state
import { chromium } from 'playwright';
const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto('https://example.com', { waitUntil: 'domcontentloaded' });
await page.getByRole('button', { name: 'Load results' }).click();
await page.locator('[data-testid="results-loading"]').waitFor({ state: 'visible' });
await page.screenshot({ path: 'results-loading.png' });
await browser.close();
If clicking the button triggers a request, do not wait for that request to finish before taking the transitional screenshot. Wait for the loading state that the user sees.
Capture one element or the full page
// Element screenshot
await page.locator('[data-testid="loading-panel"]').screenshot({
path: 'loading-panel.png'
});
// Current viewport
await page.screenshot({ path: 'viewport.png' });
// Entire scrollable document
await page.screenshot({ path: 'full-page.png', fullPage: true });
Full-page capture can trigger layout changes and lazy loading. For a deliberately early state, prefer the viewport or a specific element unless the entire document is the subject of the capture.
Puppeteer: capture before later lifecycle events
Install Puppeteer:
npm install puppeteer
Capture after navigation starts
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch({ headless: true });
const page = await browser.newPage();
await page.goto('https://example.com', {
waitUntil: 'domcontentloaded',
timeout: 30_000
});
await page.screenshot({ path: 'dom-loaded.png' });
await browser.close();
Puppeteer’s navigation API supports lifecycle choices. Select the earliest event that describes the view you need rather than automatically waiting for every resource.
Wait for a loading element, then capture it
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch({ headless: true });
const page = await browser.newPage();
await page.goto('https://example.com', { waitUntil: 'commit', timeout: 30_000 });
const loading = page.locator('[data-testid="loading-spinner"]');
await loading.wait({ visible: true, timeout: 10_000 });
await loading.screenshot({ path: 'spinner.png' });
await browser.close();
Puppeteer locators support condition-oriented waiting, including visibility and stable layout conditions. Its guidance is documented in page interaction and locator documentation.
Python with Playwright
Install the package and browser:
pip install playwright
playwright install chromium
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page(viewport={"width": 1440, "height": 900})
page.goto(
"https://example.com",
wait_until="commit",
timeout=30_000,
)
page.screenshot(path="before-load.png")
browser.close()
Python: capture a known loading state
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page()
page.goto("https://example.com/dashboard", wait_until="commit", timeout=30_000)
page.locator('[data-testid="loading-spinner"]').wait_for(
state="visible",
timeout=10_000,
)
page.screenshot(path="loading-state.png")
browser.close()
Why fixed sleeps and network idle often fail
A fixed delay assumes the same server speed, device speed, cache state, and JavaScript schedule every time. It can capture too early on a slow run and too late on a fast run.
networkidle also describes traffic, not pixels. A page can have no active connections while a framework is still applying state, while a loading screen remains visible, or while an animation is between frames. Conversely, analytics, polling, WebSockets, or advertisements can keep connections open after the target view is already visible. Playwright explicitly marks networkidle as discouraged for tests and recommends web assertions instead.
Use an assertion that expresses the visual state:
await expect(page.locator('[data-testid="partial-card"]')).toBeVisible();
await expect(page.locator('[data-testid="loading-overlay"]')).toBeVisible();
await page.screenshot({ path: 'target-state.png' });
For stable visual regression, use a screenshot assertion or equivalent comparison. Playwright’s screenshot assertion waits for two consecutive screenshots to match before comparing them. That behavior is useful for stable baselines, but it is the wrong tool when your goal is to preserve an earlier transitional frame.
Controlling the exact frame
Disable animations for deterministic captures
await page.addStyleTag({
content: `
*, *::before, *::after {
animation: none !important;
transition: none !important;
caret-color: transparent !important;
}
`
});
Do this only when you want a deterministic state. If the animation itself is the subject of the screenshot, leave it enabled and define the trigger more precisely.
Freeze after the first paint-like condition
await page.waitForFunction(() => {
const el = document.querySelector('[data-testid="partial-card"]');
return el && getComputedStyle(el).visibility !== 'hidden';
}, null, { timeout: 10_000 });
await page.screenshot({ path: 'first-visible-card.png' });
Prefer a semantic selector or application assertion when possible. A generic style check can pass while the element is still outside the viewport or covered by an overlay.
Use a finite timeout and report the failed condition
try {
await page.locator('[data-testid="loading-spinner"]').waitFor({
state: 'visible',
timeout: 8_000
});
await page.screenshot({ path: 'loading.png' });
} catch (error) {
console.error('Loading spinner did not become visible within 8 seconds');
throw error;
}
Common errors and fixes
| Symptom | Likely cause | Fix |
|---|---|---|
| Blank screenshot | Capture occurred at commit before the first visible paint. |
Wait for domcontentloaded or a visible loading selector. |
| Fully loaded page instead of loading view | The script waited for load, networkidle, or a long sleep. |
Capture at commit, domcontentloaded, or the loading assertion. |
| Timeout waiting for selector | The selector is wrong, the state is not rendered in this environment, or navigation failed. | Inspect the URL and DOM, use a stable test attribute, and log the current page URL before rethrowing. |
| Flaky screenshots | Animations, fonts, ads, random data, or responsive layout vary between runs. | Fix viewport and locale, disable animations when appropriate, wait for a semantic condition, and block or mock nonessential content. |
| Element screenshot is clipped | The element has zero size, is outside the viewport, or is covered. | Wait for visibility and a stable bounding box, then scroll it into view before capture. |
| Full-page screenshot changes the state | Scrolling triggered lazy loading or sticky-header behavior. | Use a viewport or element capture for transient states, or explicitly account for lazy loading. |
| Screenshot differs across machines | Different browser versions, fonts, device scale factors, or timezone. | Pin the browser version and viewport, install the same fonts, and set locale, timezone, and scale factor explicitly. |
| Navigation never settles | Polling, WebSockets, analytics, or third-party requests remain active. | Do not require network idle; wait for the target UI condition and use a finite timeout. |
Reliability and performance checklist
- Define the exact intermediate state before writing the wait.
- Use
commitonly when the earliest document stage is the intended image. - Prefer a visible selector or assertion for application states.
- Set a finite navigation and condition timeout.
- Record the URL, viewport, browser version, and condition that succeeded.
- Use viewport or element capture for fast transient-state jobs; full-page screenshots can require more layout and scrolling work.
- Control fonts, animations, timezone, locale, and device scale when comparing images.
- Retry only transient browser or network failures. Repeating a failed selector wait without diagnosing the state will not make the target view appear.
- Keep screenshots and logs together so a timeout can be reproduced.
Or skip the browser setup
ScreenshotNeo provides a website screenshot API when you want a capture without managing Playwright or Puppeteer. Its request returns PNG, JPEG, WebP, or PDF output. You can configure waits and other capture options through the API; see the ScreenshotNeo 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,
)
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 failed: ${res.status}`);
const buffer = Buffer.from(await res.arrayBuffer());
require('node:fs').writeFileSync('shot.webp', buffer);
ScreenshotNeo removes cookie and consent banners, newsletter popups, and chat widgets before capture. Bot checks, blank pages, failed loads, timeouts, and cache hits are not billed, and response headers identify the page verdict and billing result. Its MCP server lets Claude, Cursor, and other MCP clients call take_screenshot, get_page_info, and capture_pdf. The free plan includes 1,000 screenshots per month with no card; paid plans start at $5 for 3,000 shots. Create a free ScreenshotNeo account.
FAQ
Can a screenshot include pixels that have not rendered yet?
No. The screenshot contains only the pixels available when the browser captures the page. You can capture earlier or later, but you cannot capture future content.
Which Playwright wait is best for a loading screen?
Navigate with an early milestone such as commit, then wait for the loading screen’s selector to be visible. The selector expresses the state more directly than a network event.
Should I use a delay to capture a transitional frame?
Use a delay only when the delay itself is the requirement, such as documenting a frame 200 ms after an action. For repeatable automation, prefer an observable condition.
Is full-page capture appropriate before rendering finishes?
Usually not. Full-page capture can scroll and trigger lazy loading. Use the viewport or the specific element that represents the intermediate state.
How do I capture a loading state after a button click?
Click the button, wait for the loading indicator or skeleton to become visible, and screenshot immediately before waiting for the request or final content.


