ScreenshotNeo

BlogHow-to

How to Fix Puppeteer Screenshots with Missing CSS Styles

Diagnose missing CSS in Puppeteer screenshots by checking resource requests, page readiness, HTML origins, viewport settings, and media queries.

By the ScreenshotNeo team4 October 20269 min read

If a Puppeteer screenshot is missing styles, first check whether the page’s CSS and font requests succeeded, then confirm the page was ready, the capture used the intended viewport and media type, and the HTML had valid stylesheet URLs. A screenshot records the browser’s rendered page; Puppeteer cannot show CSS that failed to load or was blocked. The order below helps distinguish a loading problem from a timing, HTML, or CSS logic problem.

The basic capture pattern is:

import puppeteer from 'puppeteer';

const browser = await puppeteer.launch();
try {
  const page = await browser.newPage();
  await page.setViewport({ width: 1280, height: 800 });
  await page.goto('https://example.com', { waitUntil: 'networkidle2' });
  await page.screenshot({ path: 'page.png', fullPage: true });
} finally {
  await browser.close();
}

Puppeteer’s screenshot guide uses networkidle2 as a starting point before capture. That event does not prove that a particular stylesheet loaded or that a client-rendered application reached the state you want. For a real page, use the readiness condition that matches the app.

1. Check stylesheet and font requests first

Collect evidence before changing the screenshot call. Log failed requests, unsuccessful CSS and font responses, browser console errors, and the request resource type. An HTTP response can arrive and still be an error status, so inspect both the response and request-failure events.

import puppeteer from 'puppeteer';

const url = 'https://example.com';
const browser = await puppeteer.launch();
try {
  const page = await browser.newPage();

  page.on('request', request => {
    const type = request.resourceType();
    if (type === 'stylesheet' || type === 'font') {
      console.log('REQUEST', type, request.url());
    }
  });

  page.on('requestfailed', request => {
    console.error('FAILED', request.resourceType(), request.url(), request.failure()?.errorText);
  });

  page.on('response', response => {
    const type = response.request().resourceType();
    if ((type === 'stylesheet' || type === 'font') && !response.ok()) {
      console.error('HTTP', response.status(), type, response.url());
    }
  });

  page.on('console', message => {
    if (message.type() === 'error') console.error('CONSOLE', message.text());
  });

  await page.setViewport({ width: 1280, height: 800 });
  await page.goto(url, { waitUntil: 'networkidle2', timeout: 60000 });
  await page.screenshot({ path: 'page.png', fullPage: true });
} finally {
  await browser.close();
}

Look for a CSS or font request marked failed, aborted, or returned with an error status. Open the reported URL and check whether it is reachable from the environment running Chromium. For protected assets, verify the required cookies, authentication, request headers, and origin rules; those vary by site and are not fixed by changing screenshot options.

2. Audit request interception

Interception is a common self-inflicted cause. Once page.setRequestInterception(true) is enabled, each request stalls until it is continued, answered, aborted, or completed from cache. A rule intended to block images, analytics, or ads can accidentally block stylesheets and fonts too. Puppeteer’s documentation describes this behavior in its request interception reference.

await page.setRequestInterception(true);
page.on('request', request => {
  const type = request.resourceType();
  const shouldBlock = type === 'image'; // Keep stylesheet and font requests.

  if (shouldBlock) {
    void request.abort();
  } else {
    void request.continue();
  }
});

Keep the handler synchronous in its decision and ensure every branch settles the request. If several listeners or asynchronous handlers can act on the same request, avoid attempting to settle it twice. Temporarily disable interception to check whether it is responsible.

3. Wait for the page’s actual ready state

Navigation completion and application readiness are different. A stylesheet may load while JavaScript is still adding classes or content, or an application can inject styles after initial navigation. Start with networkidle2, then wait for a meaningful selector or app state.

await page.goto(url, { waitUntil: 'networkidle2', timeout: 60000 });
await page.waitForSelector('[data-app-ready="true"]', { timeout: 15000 });
await page.screenshot({ path: 'page.png', fullPage: true });

Replace the selector with an element or state your application sets only after it is visually ready. If the app exposes a stable JavaScript readiness condition, Puppeteer can wait for a function:

await page.waitForFunction(() => window.appReady === true, { timeout: 15000 });

Some pages keep connections active, so network idle may never occur or may not mean what you need. In those cases wait for a specific state rather than adding an arbitrary delay. A short delay can be useful for a known animation or delayed transition, but it can conceal a failed resource or race instead of fixing it.

4. Verify how the page HTML and CSS are supplied

When using page.setContent(html), verify the HTML contains its style block or stylesheet link and that every linked asset URL resolves. Relative paths depend on the document’s URL and base context; this is a browser URL-resolution consideration, so inspect the actual resolved request URLs instead of assuming the original site’s paths still apply.

const page = await browser.newPage();
await page.setContent(`
  <!doctype html>
  <html>
    <head>
      <style>body { font: 18px sans-serif; color: #123; }</style>
      <link rel="stylesheet" href="https://example.com/assets/site.css">
    </head>
    <body><main>Rendered content</main></body>
  </html>
`, { waitUntil: 'networkidle0' });

For a quick isolation check, inject known CSS with addStyleTag. If it appears, the screenshot mechanism can render styles and the remaining issue is in the original stylesheet path, response, or cascade.

await page.addStyleTag({ content: 'body { background: #fff; color: #222; }' });

Puppeteer also accepts a stylesheet URL with addStyleTag({ url }). For normal application behavior, navigating to the live URL often preserves its ordinary origin and asset context, but still check the actual response and resource requests. Chrome’s developer article shows the related technique of capturing stylesheet responses and inlining them as style elements when preparing rendered HTML: Headless Chrome: an answer to server-side rendering JavaScript sites.

5. Set the viewport and media before capture

Responsive CSS can produce a different layout at a different viewport width. Set the viewport before navigation so the page initially lays out at the intended dimensions. Puppeteer notes that changing viewport after load can trigger a reload in some cases and recommends emulating before navigation.

await page.setViewport({ width: 1440, height: 900, deviceScaleFactor: 1 });
await page.goto(url, { waitUntil: 'networkidle2' });

For mobile or device-specific behavior, use the intended device emulation before navigation. Check width, height, scale factor, touch behavior, and user agent against the target capture. A page can have loaded CSS correctly while desktop-only or mobile-only media rules hide the styles you expected.

If the page uses print-specific or screen-specific rules, set the media type deliberately and then capture. Do not assume a screenshot uses print CSS by default. Puppeteer exposes emulateMediaType() for selecting screen, print, or the default behavior; see the Page API.

await page.emulateMediaType('screen');
// Navigate or wait for the relevant page state, then capture.
await page.screenshot({ path: 'screen.png' });

6. If requests succeeded, inspect the CSS result

When stylesheet requests succeeded, investigate CSS logic rather than Puppeteer. Check the rendered DOM for the expected element, inspect its computed styles, and confirm the expected class or application state was applied. Then check whether a more specific rule, a media query, an animation, or a later style update changes the result. A successful CSS response alone does not guarantee that a selector matched.

Compare the screenshot with the page in a regular browser using the same URL, viewport, authentication state, and media setting. That helps separate an environment or state difference from a capture problem.

7. Common errors and fixes

Symptom Likely cause What to do
CSS or font request is failed or aborted Interception rule, network access, or server response Log URL and resource type; continue required requests; check status and access rules.
Every request hangs after interception is enabled A request handler does not settle every request Ensure each path calls continue, abort, or respond exactly once; temporarily disable interception.
Navigation timeout exceeded The chosen lifecycle condition never occurs, the site is slow, or active connections remain Check failures and page activity; use a page-specific readiness selector or function when appropriate.
Inline CSS works but linked CSS does not Bad URL, inaccessible asset, or relative path resolved against an unexpected base Log the resolved request URL; use a valid absolute URL, correct base context, or inline CSS for isolation.
CSS loaded, but layout differs Viewport, device emulation, media query, or app state differs Set viewport before navigation and check the intended media and DOM state.
Styles appear intermittently Capture races app rendering, font loading, or delayed style changes Wait for the app’s visual-ready condition and inspect font and stylesheet responses on failing runs.
Screenshot is blank or mostly unstyled Wrong URL/content, failed navigation, or failed critical resources Inspect navigation response, page URL/title, console, and failed resources before capture.

8. Keep captures reliable and efficient

  • Use explicit timeouts. Bound navigation and readiness waits so a stuck page does not hold a worker forever. Choose limits suitable for the pages you capture.
  • Record enough diagnostics. For intermittent failures, retain the URL, viewport, navigation response status, failed CSS/font URLs, console errors, and the readiness condition that timed out.
  • Reuse browser processes carefully. Reusing a browser can avoid repeated startup work, but isolate page state and close pages when finished. Do not share mutable page settings across concurrent captures.
  • Wait for a useful condition. Waiting for all network activity can be slower or impossible on pages with persistent connections; a stable app-specific condition can reduce needless waiting while preserving correctness.
  • Do not block critical resources to chase speed. Removing stylesheets or fonts makes captures cheaper only in the sense of fewer requests, while directly defeating a faithful styled screenshot. If blocking requests, allow CSS, fonts, and any scripts needed to reach the target state.

There is no universal timeout or speed figure for these pages. Asset count, server response, browser environment, and application behavior determine the cost of a capture. Diagnose with the same conditions you intend to use in production.

9. Or skip the browser setup

ScreenshotNeo is a website screenshot API and MCP server. Make one request with the target URL; the API returns an image or PDF. Its clean-shot flow accepts cookie and consent banners like a visitor and removes more than 60 known consent platforms, newsletter popups, and chat widgets, and each step can be turned off. Bot checks, blank pages, failed loads, timeouts, and cache hits cost nothing; responses identify the page verdict and billing status in headers. Its MCP server provides screenshot tools for Claude, Cursor, and other MCP clients.

Here is a complete cURL call. Create an API key in your account and replace the placeholder:

curl -G "https://api.screenshotneo.com/v1/shot" \
  -d access_key=YOUR_API_KEY \
  --data-urlencode url=https://example.com \
  -o shot.webp

The equivalent Python request:

import requests

r = requests.get(
    "https://api.screenshotneo.com/v1/shot",
    params={"access_key": "YOUR_API_KEY", "url": "https://example.com"},
    timeout=90,
)
r.raise_for_status()
open("shot.webp", "wb").write(r.content)

And Node.js:

const q = new URLSearchParams({
  access_key: 'YOUR_API_KEY',
  url: 'https://example.com',
});
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot request failed: ${res.status}`);
const bytes = new Uint8Array(await res.arrayBuffer());
await import('node:fs/promises').then(fs => fs.writeFile('shot.webp', bytes));

See the ScreenshotNeo API documentation for request parameters and response details. The API supports PNG, JPEG, WebP, or PDF, and includes options for viewport and device presets, full-page and element capture, custom CSS and JavaScript, waiting conditions, headers and cookies, caching, asynchronous jobs, and bulk capture. Every feature is available on every plan.

1,000 screenshots per month are free with no card; paid plans start at $5 for 3,000 screenshots. Cookie banners, popups, and chat widgets are removed before the shot; bot checks, blank pages, and failed loads are never billed. AI agents can take screenshots through the MCP server.

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

FAQ

Does Puppeteer remove CSS from screenshots?

No. A screenshot reflects the browser’s rendered page. If styles are missing, investigate failed or blocked assets, timing, HTML context, viewport, media, or CSS matching.

Should I always use networkidle2?

It is a useful starting point from Puppeteer’s screenshot guide, but applications with persistent connections or delayed client rendering may need a page-specific readiness condition.

Why does setContent() show different styles than navigation?

The supplied HTML may not share the original page’s URL context, asset paths, cookies, or application state. Check the resolved stylesheet requests and compare with navigation to the live page.

Can I use addStyleTag() to fix production captures?

Yes, when you intentionally need to inject CSS. As a diagnostic, it can show whether rendering works while helping isolate a problem in the page’s original stylesheet loading.