Fix Incorrect CSS in Puppeteer Screenshots After Page Load
Diagnose screenshots with incorrect CSS by checking page readiness, stylesheet and font requests, computed styles, viewport settings, and browser versions.
Puppeteer captures the rendered page state that exists when page.screenshot() runs. If CSS looks wrong even after navigation completes, first check whether the page reached its actual visual-ready state. Then verify stylesheet and font requests, inspect computed styles, match the viewport and emulation settings, and compare the Puppeteer and browser versions.
waitUntil: 'networkidle2' is a useful navigation wait, but it does not prove that client-side code has finished changing the page. Prefer an application-specific readiness signal. If fonts may be involved, also await document.fonts.ready. The right diagnosis depends on the page, Puppeteer version, and selected browser.
1. Capture only after the page is visually ready
Puppeteer’s screenshot guide shows navigation followed by page.screenshot(), using networkidle2 as one possible navigation condition. Network quiet is a lifecycle condition, not a guarantee that a particular application has finished rendering its intended state. A page may still fetch data, update components, insert styles, or reveal content after navigation.
Wait for the condition that makes the target page ready for your screenshot. Good signals include an application-owned data-render-ready marker, a loading overlay disappearing, or a predicate that checks the content and state you need. A selector’s presence proves only that the element exists; it may appear before its final styles or content.
import puppeteer from 'puppeteer';
const url = 'https://example.com';
const browser = await puppeteer.launch({ headless: true });
try {
const page = await browser.newPage();
await page.setViewport({ width: 1440, height: 1000, deviceScaleFactor: 1 });
await page.goto(url, { waitUntil: 'networkidle2' });
await page.waitForFunction(() =>
document.documentElement.dataset.renderReady === 'true'
);
await page.evaluate(() => document.fonts.ready);
await page.screenshot({ path: 'page.png', fullPage: true });
} finally {
await browser.close();
}
This is a pattern to adapt, not a universal readiness recipe. If the site has no readiness marker, add one in the application or test harness, or wait for an observable state that represents the actual content you need. Puppeteer’s Page API documents selector, function, and network-idle waits: Page class API.
Choose the wait condition that proves the right thing
| Wait | What it tells you | What it does not prove |
|---|---|---|
goto(..., { waitUntil: 'load' }) |
The navigation reached the load lifecycle event. | That client-side rendering or later application work is finished. |
waitUntil: 'networkidle2' |
Network activity met Puppeteer’s network-idle condition. | That all visual changes are complete or that the app is ready for this capture. |
waitForSelector() |
A matching DOM element exists, with visibility options available. | That the element has its final styles, content, or layout. |
waitForFunction() |
A predicate about page state has become true. | Anything not covered by the predicate; define it around the actual capture requirement. |
document.fonts.ready |
Used fonts have finished loading and associated layout operations are complete. | That every declared optional font loaded, or that stylesheets, images, and app data are ready. |
Use a delay only as a diagnostic. If adding a delay changes the screenshot, some work is probably completing late; replace the sleep with a readiness condition tied to that work. A fixed delay can be too short on a slow run and waste time on a fast one.
2. Check font readiness and font delivery
When text has the wrong shape, width, weight, or line breaks, compare the intended font with the font the browser actually uses. MDN documents document.fonts and its ready promise: it resolves after used fonts finish loading and layout operations complete. Await it after the application reaches the relevant state and before capture:
await page.evaluate(() => document.fonts.ready);
const fontCheck = await page.$eval('.headline', element => ({
declared: getComputedStyle(element).fontFamily,
status: document.fonts.status,
available: document.fonts.check('16px "Example Sans"')
}));
console.log(fontCheck);
Replace .headline and Example Sans with the target element and expected family. The computed font-family is a declaration and fallback list; it does not by itself prove which face rendered. Check the browser’s font request results as well. A declared face might be optional, unused, unavailable, or unsuccessful, so document.fonts.ready is not proof that every face in the stylesheet loaded.
For a reproducible comparison, capture the same page after the readiness wait and inspect both the computed font declaration and failed font requests. The Chrome Developers rendering overview explains the separate roles of stylesheets and fonts in rendering: Headless Chrome and server-side rendering JavaScript sites.
3. Verify that the stylesheet loaded and affected the target
A CSS rule in a source file does not prove that the browser fetched the stylesheet, accepted it, or applied that rule to the element in your screenshot. Check the request and response for the stylesheet, then inspect the target’s computed values in the same page and frame that Puppeteer captures.
const styles = await page.$eval('.target', element => {
const style = getComputedStyle(element);
return {
color: style.color,
display: style.display,
fontFamily: style.fontFamily,
fontSize: style.fontSize,
lineHeight: style.lineHeight,
margin: style.margin,
width: style.width
};
});
console.log(styles);
If the values do not match expectations, investigate the applicable cascade and rendering context:
- Confirm the stylesheet request succeeded and the response contains the expected CSS.
- Check whether a more specific selector or later rule overrides the expected declaration.
- Check media queries against the viewport and emulated media type.
- For dynamically inserted styles, confirm the relevant insertion has happened before the readiness signal.
- If the element is inside a shadow root or another frame, inspect that styling context rather than assuming the top-level document’s rules apply.
- Check the actual target selector and computed property. A visually similar but different element may be receiving the rule.
These are diagnostic possibilities, not a claim that one cause applies to every page. The request log and computed style narrow down whether the issue is delivery, cascade, timing, or capture context.
4. Match viewport, emulation, and browser versions
CSS can change legitimately with viewport breakpoints, device scale factor, media emulation, user agent, or device emulation. Record the capture inputs before comparing a screenshot with a manual browser view. Apply the intended viewport before navigation and use the same settings in both runs.
const browser = await puppeteer.launch({ headless: true });
try {
const page = await browser.newPage();
await page.setViewport({
width: 1440,
height: 1000,
deviceScaleFactor: 1
});
// Set any required device or media emulation here, before navigation.
await page.goto('https://example.com', { waitUntil: 'networkidle2' });
// Wait for application readiness, then capture.
} finally {
await browser.close();
}
Include the Puppeteer package version and Chrome/Chromium product version in a bug report. Puppeteer can control a bundled or separately selected browser, so the package version alone does not fully describe the rendering environment. Pin the pair when checking whether a change began after an update.
A historical Puppeteer issue reported inconsistent headless text rendering on an older stack: issue #2410. It shows that environment-specific font rendering problems have occurred; it is not evidence of a current general defect or a reason to copy an old launch-argument workaround without reproducing the same symptom on your versions.
5. Diagnose in a repeatable order
- Record the environment. Capture Puppeteer version, browser product and version, headless mode, viewport dimensions, device scale factor, and device or media emulation.
- Pick a navigation wait. Start with the lifecycle condition that fits the page.
networkidle2is a useful starting point, not the final visual-ready test. - Wait for the page-specific state. Use a readiness marker or predicate that represents the content and visual state needed for the screenshot.
- Wait for used fonts if relevant. Await
document.fonts.ready, then check the computed declaration and font request outcomes. - Inspect stylesheet requests and computed styles. Determine whether the CSS arrived and whether the target has the expected values.
- Match capture settings. Compare only after viewport and emulation inputs match.
- Compare pinned versions. If the issue appeared after an update, reproduce with the previous and current Puppeteer/browser pair.
- Replace diagnostic sleeps. Turn any delay that affects the result into a wait for the underlying state or resource.
The official screenshot guide and Page.screenshot() reference describe the capture flow and API: Puppeteer Screenshots guide and Page.screenshot() API. Documentation shown in the research is version 25.12.0; your installed package and selected browser may differ.
Or skip the browser setup
If your goal is a clean screenshot rather than debugging a Puppeteer rendering pipeline, ScreenshotNeo returns a screenshot or PDF from one GET request. 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}`);
ScreenshotNeo accepts cookie or consent banners and removes more than 60 known consent platforms, newsletter popups, and chat widgets before capture; each step can be turned off. Bot checks, blank pages, timeouts, failed loads, and cache hits cost nothing, 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 screenshots a month with no card; paid plans start at $5 for 3,000 shots.
Create a free ScreenshotNeo account for 1,000 screenshots a month with no card.
Troubleshooting
| Symptom | Likely cause to check | What to do |
|---|---|---|
| Screenshot has default fonts or unexpected line breaks | Font request failed, font was not used or ready at capture, or fallback rendered. | Await document.fonts.ready after app readiness; inspect font requests and computed font styles. |
| Styles look unstyled or partly missing | Stylesheet failed, arrived late, or the expected rule did not apply. | Inspect stylesheet response and computed values for the affected element; check selector, cascade, media query, and frame. |
| Adding a sleep fixes the image intermittently | Some app or resource work finishes after the current capture point. | Identify that work and wait for its state or request outcome instead of relying on a fixed delay. |
| Manual browser looks correct but screenshot differs | Viewport, device scale factor, media/device emulation, headless mode, or browser version differs. | Record and match the settings and versions before comparing. |
| Network idle wait never finishes | The page may keep network activity open or ongoing. | Use a suitable navigation lifecycle wait, then wait for the app-specific visual readiness condition. |
| Font wait completes but typography is still wrong | The intended face may be optional, unused, unavailable, or overridden. | Check the font request, computed font declaration, applicable CSS, and fallback behavior. |
| Only one embedded component is incorrect | It may be styled in another frame or shadow root, or have its own readiness timing. | Inspect that component’s own styling context and wait condition. |
Performance and reliability notes
- Choose a wait that matches the page. Waiting for every network request to stop can be a poor fit for pages with ongoing requests; an app-owned readiness signal is often more directly tied to the desired image.
- Wait for fonts only when font loading can affect the capture, and do so after the state that uses them is present.
- Keep viewport, emulation, and browser versions explicit. Reproducible inputs make visual differences easier to isolate.
- Prefer state-based waits to generous fixed delays. They reduce unnecessary waiting while avoiding screenshots taken too early.
- There is no sourced universal benchmark or failure rate for incorrect CSS screenshots. Measure timing and repeatability in the affected application rather than assuming a general performance cost.
FAQ
Does networkidle2 guarantee the final CSS is applied?
No. It is a navigation/network condition. The application may continue visual work afterward, so wait for the page-specific state you need.
Should I always wait for document.fonts.ready?
Only when used fonts can affect the capture. It covers used font loading and layout completion, not every optional font or other page resource.
Does a CSS rule appearing in source mean it affected my element?
No. Verify delivery and inspect the target’s computed style in the frame and styling context being captured.
Can a Puppeteer or Chromium update change screenshot output?
Rendering differences can be environment- and version-dependent. Record both versions and reproduce with pinned pairs before attributing a change to CSS or a browser defect.


