Because Puppeteer is returning two different representations. page.content() serializes the page’s live DOM (including the DOCTYPE) into text. page.screenshot() records pixels after Chromium applies layout, CSS, fonts, image decoding, clipping, compositing and the current animation frame. The DOM can therefore be correct while the image shows a different viewport, asset state or moment in the page lifecycle.
The two APIs do not return the same kind of data
Puppeteer’s Page API describes page.content() as “The full HTML contents of the page, including the DOCTYPE.” That is serialized markup at the instant you call it. The screenshot API, by contrast, “Captures a screenshot of this page.” It rasterizes what Chromium has painted.
| Question | page.content() |
page.screenshot() |
|---|---|---|
| Output | HTML string, including the current document markup and DOCTYPE | PNG, JPEG or WebP pixels |
| Includes computed styles? | No. It preserves attributes, style elements and links, not the browser’s computed values. | Yes, insofar as they affect painted pixels. |
| Includes decoded image data? | No. It contains image elements and URLs, not rasterized image pixels. | Yes, if the images were loaded and painted when the capture occurred. |
| Includes fonts and glyph layout? | No. Font selection and glyph rasterization are rendering decisions. | Yes. Font fallback, hinting and line wrapping change the image. |
| Time sensitivity | Reflects the live DOM at one instant. | Reflects the rendered frame, viewport, scroll position and browser state at one instant. |
| Generated content and compositing | CSS pseudo-elements, clipping and compositing are not represented as ordinary child HTML. | They appear when Chromium paints them. |
Consequently, matching HTML text is not a visual correctness test. A browser can apply a stylesheet, resolve a web font, decode an image, hide overflow or draw a ::before pseudo-element without changing the serialized child markup.
Timing is the most common source of divergence
page.goto() completing does not mean an application is visually ready. After navigation, JavaScript may fetch data, replace nodes, toggle classes, inject styles, hydrate a server-rendered tree or render into a shadow root. Calling the two APIs at different points captures two different states.
The Tool Desk
Outbyte Driver Updater FREEScan for outdated or missing drivers - takes under a minuteDriver Scan →Outbyte PC Repair FREEClear out junk files and repair common Windows errorsFree Scan →#1 Best Overall
Use an application readiness signal
Have the application set a finite, test-only signal after the content that matters has rendered:
<script>
window.__VISUAL_READY__ = true;
</script>
In the test, wait for that signal or for a specific ready attribute. A generic navigation event remains useful as a starting point, but it is not a substitute for an application-specific condition. Always record the final URL because redirects can select a different locale, experiment or login state.
Capture both representations at one controlled point
Do not serialize the DOM, wait several seconds, and then take the screenshot. Perform the final readiness checks once, then call page.content() and page.screenshot() back-to-back. Save the URL, viewport, device scale factor, user agent, browser version and emulation settings beside the artifacts.
Viewport, device scale and media settings can change the layout
Responsive CSS reacts to viewport dimensions and user-agent conditions. A breakpoint can reflow columns, hide a navigation bar or select mobile markup while leaving most of the HTML unchanged. Device scale affects raster dimensions and can expose rounding differences in borders and text.
Free tools Windows power users keep installed
One-click scans. No signup required.
Rank #2
- Set the viewport and device scale factor before navigation.
- Use the same user agent for every comparison, or use
page.emulate()with a fixed device profile. - Fix color scheme, reduced-motion preference, locale, timezone and geolocation when those values influence the page.
- Keep the Chromium version and operating-system font set constant in CI.
await page.setViewport({
width: 1440,
height: 900,
deviceScaleFactor: 1
});
await page.emulateMediaFeatures([
{ name: 'prefers-color-scheme', value: 'light' },
{ name: 'prefers-reduced-motion', value: 'reduce' }
]);
If the screenshot is unexpectedly mobile, verify the viewport before investigating CSS. If text wraps differently on another runner, compare installed fonts and browser versions before blaming the DOM.
Fonts and images must be ready before a visual capture
A present HTML element is not proof that its pixels are ready. A web font can still be downloading, so the browser temporarily lays out fallback glyphs. An image element can exist while its resource is undecoded, have a failed request, or report a zero intrinsic width. Either condition changes the screenshot without necessarily changing page.content().
A bounded readiness check
await page.evaluate(async () => {
if (document.fonts) await document.fonts.ready;
const images = Array.from(document.images);
await Promise.all(images.map(async image => {
if (!image.complete) {
await new Promise(resolve => {
image.addEventListener('load', resolve, { once: true });
image.addEventListener('error', resolve, { once: true });
});
}
if (image.decode) {
try { await image.decode(); } catch (_) {}
}
}));
});
Apply an overall timeout around this check. It covers current document images, not images inserted later, CSS background images, video frames, canvas drawing or assets inside every shadow root. For those, wait on the application’s own signal and inspect the relevant component.
Lazy loading, animation and fullPage capture
What fullPage: true actually does
fullPage: true captures the document’s current full height. It does not automatically discover or load infinite-scroll content. A page can therefore have a complete DOM for the currently loaded items while the screenshot still omits content that appears only after scrolling.
Trigger lazy content deliberately
If the page uses viewport-based lazy loading, scroll in finite increments, wait for the expected network or DOM condition, and stop at a known end state. Scrolling changes the DOM and can alter the final image, so return to the intended position before a viewport capture.
await page.evaluate(async () => {
const step = Math.max(400, window.innerHeight);
for (let y = 0; y < document.body.scrollHeight; y += step) {
window.scrollTo(0, y);
await new Promise(resolve => setTimeout(resolve, 150));
}
window.scrollTo(0, 0);
});
await page.waitForFunction(
() => document.querySelector('[data-infinite-scroll-done="true"]'),
{ timeout: 10000 }
).catch(() => {});
await page.screenshot({ path: 'full.png', fullPage: true });
Do not use an unbounded “scroll until height stops changing” loop against an endlessly growing feed. Define a maximum number of passes and an explicit completion condition.
Freeze or await motion
Two screenshots can differ by animation frame even when their HTML strings are identical. Prefer a reduced-motion media setting, disable test-owned transitions with a temporary stylesheet, or wait for a deterministic end class. Record whether the capture is viewport, element-clipped or full-page; those modes have different scroll and clipping behavior.
A deterministic Puppeteer capture recipe
The following Node.js script fixes the important variables, waits for a page-owned signal, checks fonts and images, and saves both artifacts from the same point. Replace the URL and selector with those used by your application.
Quick wins for a faster PC:
Fix the driver behind crashes, sound loss and screen glitchesFind Drivers →Repair Windows errors before they cause bigger problemsFix Now →Scan for outdated or missing drivers - takes under a minuteDriver Scan →const puppeteer = require('puppeteer');
const fs = require('fs/promises');
(async () => {
const url = process.env.TARGET_URL || 'https://example.com';
const browser = await puppeteer.launch({ headless: 'new' });
const page = await browser.newPage();
await page.setViewport({ width: 1440, height: 900, deviceScaleFactor: 1 });
await page.emulateMediaFeatures([
{ name: 'prefers-color-scheme', value: 'light' },
{ name: 'prefers-reduced-motion', value: 'reduce' }
]);
await page.goto(url, { waitUntil: 'networkidle2', timeout: 60000 });
await page.waitForFunction(
() => window.__VISUAL_READY__ === true,
{ timeout: 15000 }
).catch(async () => {
await page.waitForSelector('main', { timeout: 5000 });
});
await page.evaluate(async () => {
if (document.fonts) await document.fonts.ready;
await Promise.all(Array.from(document.images).map(async image => {
if (!image.complete) await new Promise(resolve => {
image.addEventListener('load', resolve, { once: true });
image.addEventListener('error', resolve, { once: true });
});
if (image.decode) { try { await image.decode(); } catch (_) {} }
}));
});
const required = await page.$('main');
if (!required) throw new Error('Required main element is missing');
const box = await required.boundingBox();
if (!box || box.width <= 0 || box.height <= 0) {
throw new Error('Required element has no visible geometry');
}
const html = await page.content();
await fs.writeFile('page.html', html, 'utf8');
await page.screenshot({ path: 'page.png', fullPage: false });
console.log(JSON.stringify({
finalUrl: page.url(),
viewport: await page.viewport(),
htmlBytes: Buffer.byteLength(html),
screenshot: 'page.png'
}, null, 2));
await browser.close();
})().catch(error => {
console.error(error);
process.exitCode = 1;
});
For a full document image, change only the screenshot option to { path: 'page.png', fullPage: true } after you have handled lazy content. For a component comparison, use page.locator('selector').screenshot() or an element handle and keep the selector stable.
Diagnostic workflow when the files disagree
- Save the response URL and
page.url(), viewport, device scale, user agent, browser version, color scheme, locale and scroll position. - Confirm the readiness condition actually fired. Log its timestamp and any late network requests.
- Inspect fonts, image
naturalWidth, positive element geometry, CSS backgrounds, shadow roots and generated content. - Check timers, transitions, carousels and video. Freeze them or capture at a defined state.
- Capture HTML and the screenshot at the same controlled point, then compare image regions as well as DOM snapshots.
- Re-run with fixed assets and animation state. Restore variables one at a time to identify which change causes the mismatch.
For visual defects, image comparison is essential. DOM-only analysis can miss a missing font, incorrect compositing, a clipped overflow region or a browser rendering incompatibility.
Common symptoms and fixes
| Symptom | Likely cause | Practical fix |
|---|---|---|
| HTML contains an image, but the screenshot shows a blank box | Request failed, image is undecoded, lazy loading has not run, or CSS hides it. | Check the request and naturalWidth; await load and decode(); trigger the lazy-loading condition. |
| Text wraps differently between machines | Font fallback, browser version, device scale or viewport width differs. | Install/pin fonts, browser and viewport; await document.fonts.ready. |
| Screenshot has a banner or popup absent from expected HTML | A late script injected it, or the screenshot was taken before the dismissal code ran. | Wait for the dismissal state and inspect post-load mutations and timers. |
| Only the bottom of a full-page image is wrong | Lazy content was never triggered, or page height changed during capture. | Use a bounded scroll plan, wait for a completion signal and capture after height stabilizes. |
| Repeated screenshots differ by small moving regions | Animation, caret blinking, rotating content or video frame. | Use reduced motion, disable test-owned animation and define a capture frame. |
| HTML looks right but a pseudo-element or shadow component is missing in analysis | Generated content and shadow DOM are not ordinary child markup. | Inspect computed styles and shadow roots, then validate the rendered pixels. |
| Screenshot is consistently mobile-sized | Viewport or emulation was set after navigation, or a device profile overrode it. | Set viewport/emulation before goto() and log the effective values. |
Keeping visual comparisons reliable and affordable
Determinism usually matters more than raw capture speed. Reuse a browser process for a batch, but create an isolated page per case; set a navigation timeout and a separate readiness timeout; abort or classify failed captures instead of storing partial images. Cache immutable assets in your test environment, but do not hide application regressions by caching the page itself. Store metadata with every image so a later diff can distinguish a CSS change from a browser or viewport change.
Compare stable regions first, mask timestamps and other intentionally dynamic areas, and use a threshold appropriate to your image format. A failed load, bot check or blank page should be a test failure or a separate verdict, not a false visual baseline.
Best Value
Or skip the browser setup
If you need a production screenshot rather than a local Puppeteer diagnostic, ScreenshotNeo exposes a single GET request. It can accept consent banners, remove more than 60 known consent platforms plus newsletter popups and chat widgets, and return PNG, JPEG, WebP or PDF. Each response reports its result in X-Page-Verdict and X-Billed headers: bot checks or CAPTCHAs, blank pages, timeouts, failed loads and cache hits are not billed.
Here is the smallest request; the API documentation is at https://screenshotneo.com/docs/.
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
Python
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)
Node.js
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(`${res.status} ${res.statusText}`);
require('fs').writeFileSync('shot.webp', Buffer.from(await res.arrayBuffer()));
For cases where you still need browser-level control, ScreenshotNeo supports full-page capture with lazy images loaded, CSS-selector element capture, dark mode, 12 device presets or a custom viewport, retina scale, PDF paper size/margins/landscape/page ranges, HTML/CSS-to-image, custom CSS and JavaScript, pre-capture clicks, hidden selectors, waits for a selector/delay/network idle, blocking ads/trackers/requests/resource types, custom headers/cookies/user agents/Authorization, timezone and geolocation, transparent backgrounds, resizing, a chosen cache TTL, signed public image links, asynchronous jobs with signed webhooks, bulk capture of 100 URLs per call, a usage API and an OpenAPI specification. Parameter names used by other screenshot APIs also work, which eases migration.
Its MCP server exposes take_screenshot, get_page_info and capture_pdf to Claude, Cursor and other MCP clients. Every feature is available on every plan. The Free plan includes 1,000 shots per month without a card; paid plans start at $5 for 3,000 shots, and yearly billing gives two months free.
Do these 3 things before closing this tab:
1Clear out junk files and repair common Windows errors2Scan for outdated or missing drivers - takes under a minute3Repair Windows errors before they cause bigger problemsCreate a free ScreenshotNeo account to use the 1,000 monthly shots without a card.
Frequently Asked Questions
Can two captures be considered equivalent if their HTML strings are byte-for-byte identical?
No. Identical markup does not guarantee identical fonts, computed styles, image decode state, viewport, animation frame or browser compositing. Treat pixel output and its capture metadata as separate evidence.
Should I use a viewport or full-page screenshot for a regression test?
Use a viewport capture when the behavior users see at a fixed screen size is the target. Use full-page only when the entire document is the subject, and define how lazy or infinite content is loaded first.
What should I preserve when a visual diff fails in CI?
Keep the HTML snapshot, screenshot, final URL, browser version, viewport, device scale, user agent, media settings, readiness logs and network/error information. That record lets you identify whether the change came from markup, assets, environment or timing.
Quick Recap
Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.




