Puppeteer CSSCoverage: Measure Unused CSS
Measure which CSS ranges a Puppeteer run covers, calculate a usage estimate, and learn what coverage can—and cannot—tell you about removing styles.
Puppeteer CSS coverage shows which ranges of stylesheet text were exercised during a particular browser run. Start collection with page.coverage.startCSSCoverage(), navigate and interact with the page, then call page.coverage.stopCSSCoverage(). Sum the returned covered range lengths to estimate the fraction of CSS observed in that run. Treat absent ranges as “not covered in this run,” not as proof that the styles can be deleted.
This guide uses Puppeteer’s documented Coverage API. The referenced API documentation identifies Puppeteer 25.12.0 for Coverage and CSSCoverageOptions, and 25.10.0 for CoverageEntry; check the API reference for the version installed in your project because signatures and defaults can change.
1. Install Puppeteer and run a CSS coverage capture
Install Puppeteer in a Node.js project if it is not already present:
npm install puppeteer
Save the following as css-coverage.mjs. It starts coverage before navigation, visits a URL, optionally waits briefly for initial client-side work, stops coverage, and prints both a per-stylesheet summary and a combined character-range estimate.
import puppeteer from 'puppeteer';
const url = process.argv[2] ?? 'https://example.com';
const browser = await puppeteer.launch({ headless: true });
try {
const page = await browser.newPage();
// Begin before navigation so styles exercised during initial rendering count.
await page.coverage.startCSSCoverage();
await page.goto(url, { waitUntil: 'networkidle2', timeout: 60000 });
// Add the interactions and waits your application needs here.
// For example: await page.locator('button.menu').click();
const entries = await page.coverage.stopCSSCoverage();
let totalChars = 0;
let coveredChars = 0;
const summaries = entries.map((entry) => {
const stylesheetChars = entry.text.length;
const stylesheetCoveredChars = entry.ranges.reduce(
(sum, range) => sum + range.end - range.start - 1,
0,
);
totalChars += stylesheetChars;
coveredChars += stylesheetCoveredChars;
return {
url: entry.url,
stylesheetChars,
coveredChars: stylesheetCoveredChars,
percentCovered: stylesheetChars === 0
? 0
: (stylesheetCoveredChars / stylesheetChars) * 100,
ranges: entry.ranges.length,
};
});
const percentCovered = totalChars === 0 ? 0 : (coveredChars / totalChars) * 100;
console.log(JSON.stringify({
url,
stylesheetCount: entries.length,
totalChars,
coveredChars,
percentCovered,
stylesheets: summaries,
}, null, 2));
} finally {
await browser.close();
}
Run it with:
node css-coverage.mjs https://your-site.example/
The documented range-length expression is range.end - range.start - 1. The percentage is a character-offset estimate based on the returned stylesheet text, not a count of selectors, declarations, rules, or rendered pixels. The zero-length guard prevents a division by zero when there is no stylesheet text.
2. What the Puppeteer CSSCoverage report contains
stopCSSCoverage() resolves to an array of stylesheet coverage reports. Each coverage entry has these fields:
| Field | Meaning | How to use it |
|---|---|---|
url |
The stylesheet URL | Identify the stylesheet represented by this entry. Inline or otherwise nonstandard stylesheet sources may not have a useful external URL. |
text |
The stylesheet source text | Use its length as the denominator for the character-range estimate, or inspect its source alongside ranges. |
ranges |
An array of { start, end } offsets |
These are the covered portions of the stylesheet text for the observed run. |
The ranges are evidence about browser activity collected between the start and stop calls. A report does not identify “safe to delete” CSS. Styles may be needed on routes, viewports, or interactive states that the run never reached.
3. Choose navigation behavior deliberately
startCSSCoverage() accepts CSS coverage options. The documented CSSCoverageOptions setting is resetOnNavigation; the start method reference lists its default as true. With the default, coverage resets on each navigation. If your capture spans multiple page navigations and you need ranges accumulated across them, set it to false explicitly:
await page.coverage.startCSSCoverage({ resetOnNavigation: false });
await page.goto('https://example.com/');
await page.goto('https://example.com/about');
const entries = await page.coverage.stopCSSCoverage();
Use true (or omit the option) when you want the measurement to reset as navigation occurs. Use false when the intended measurement includes coverage across navigation. Consult the API reference matching your installed Puppeteer version if this behavior is significant to a report or automation pipeline.
4. Exercise representative pages and states
Coverage only reflects what the browser encountered while collection was active. For a useful audit, plan a run that represents the product’s actual routes and states:
- Include the routes in scope. A homepage run says little about styles only used on account, checkout, or settings pages.
- Cover relevant viewports. Responsive styles may only be exercised at particular viewport dimensions. Run separate measurements for materially different viewport conditions.
- Trigger interactive states. Open menus, dialogs, accordions, tabs, validation messages, and other states users can reach.
- Wait for the relevant UI to appear. If the page renders asynchronously, wait for a selector or a known application-ready condition before stopping coverage.
- Repeat scenarios as needed. Keep individual run results when you need to know which scenario covered a range; merge or compare them only with a clear understanding of the reporting method.
Starting collection before page.goto() includes styles exercised during initial navigation. If you start only after navigation, initial-render coverage is missing. If you stop too soon, later interactions and delayed states are missing.
5. Interpret the percentage without overclaiming
The total percentage is:
covered characters across entries / total stylesheet text characters × 100
This provides a compact comparison for the exact run and collection settings. It has important limits:
- It measures covered source ranges, not semantic CSS rules or selectors.
- A low percentage can mean the scenario was narrow, not that the remainder is dead code.
- A high percentage does not prove every relevant route, device size, state, or user path was covered.
- Repeated or overlapping ranges should be handled according to the API’s returned range data; do not reinterpret the metric as a declaration count.
- Puppeteer documents this limitation: “CSS Coverage doesn’t include dynamically injected style tags without sourceURLs.” Dynamically injected styles without source URLs therefore may not appear in this report.
Use the result to find candidates for further investigation. Before deleting a style, consider route and state coverage, application behavior, and any styling injected at runtime. Repeat the measurement after changes to check that the scenarios you care about still exercise their expected styles.
6. Save detailed evidence for inspection
For debugging or a review workflow, persist the raw entries as well as the derived summary. The text, url, and ranges let a downstream script or reviewer inspect the source offsets rather than relying only on one percentage.
import { writeFile } from 'node:fs/promises';
// After stopCSSCoverage() has returned:
await writeFile(
'css-coverage.json',
JSON.stringify(entries, null, 2),
'utf8',
);
Keep the run URL, viewport, interaction scenario, and Puppeteer version with the output if you need to compare results over time. Those details explain what the measurement actually covered.
7. Troubleshooting Puppeteer CSS coverage
| Symptom | Likely cause | Fix |
|---|---|---|
| No entries or an empty array | Coverage was stopped before a stylesheet was observed, collection began too late, the page has no stylesheet text, or navigation failed. | Start coverage before navigation, verify navigation succeeded, and inspect the page’s loaded stylesheets. |
| Zero covered ranges | The styles were not exercised during the collected interval, or the measurement stopped before the page reached the relevant state. | Wait for the page to become ready and exercise the route and UI state that use those styles. |
| Styles from a later route are absent | Coverage reset on navigation, which is the documented default for resetOnNavigation. |
Set resetOnNavigation: false if the goal is to accumulate across navigations, or collect a separate report for each route. |
| Runtime-injected style tags are missing | Puppeteer’s documented caveat excludes dynamically injected style tags without sourceURLs. | Account for those styles separately; where you control the injection, provide source URLs as appropriate and verify behavior against the Puppeteer version in use. |
| The percentage changes between runs | The routes, viewport, timing, or interactions differed, or dynamic application behavior changed what loaded. | Make the scenario repeatable: use the same viewport, route sequence, waits, and interactions, and record those settings with the output. |
Navigation times out at networkidle2 |
The page may continue network activity or not reach that lifecycle condition within the configured timeout. | Choose a lifecycle condition appropriate to the site, wait for a known selector or app-ready signal, and set a timeout suitable for your environment. Do not stop before the styles and states under study have appeared. |
| Coverage output is mistaken for deletion advice | The measurement is being generalized beyond the browser activity it observed. | Describe results as “covered” or “not covered in this run,” test additional routes and states, and review candidates before removing styles. |
8. Performance, reliability, and cost considerations
Coverage is collected inside the Puppeteer browser session, so the practical cost is the browser work you choose to run: launching a browser, loading pages, and exercising the scenarios. This workflow has no per-screenshot API charge described by the Puppeteer coverage documentation; infrastructure, browser runtime, and the target site’s own costs depend on where and how you run it.
For repeatable results, keep navigation and interaction steps deterministic, wait for meaningful page states, and capture each route or viewport that matters. Avoid treating one broad crawl as complete evidence unless it actually visits the states you need. Keep raw entries to preserve detail, and compare percentages only when the measured scenarios and calculation are consistent.
9. Or skip the browser setup
Puppeteer CSS coverage is the right tool when you need stylesheet range evidence from a browser run. If the task is to capture a page image or PDF, ScreenshotNeo offers a one-call website screenshot API; see the API documentation. A screenshot is a visual artifact, not a replacement for CSS coverage data.
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://example.com -o shot.webp
ScreenshotNeo accepts cookie and consent banners like a visitor and removes 60+ known consent platforms, newsletter popups, and chat widgets before capture; each step can be turned off. Bot checks, blank pages, failed loads, timeouts, 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 Claude, Cursor, and other MCP clients. 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 and get 1,000 screenshots a month with no card.
10. FAQ
Does CSS coverage tell me exactly which selectors are unused?
No. It reports covered text ranges, which you can use to estimate character coverage. It is not a semantic selector inventory or a safe deletion list.
Can I use this for JavaScript coverage too?
Puppeteer also exposes JavaScript coverage APIs, but this guide’s calculation and interpretation apply to CSS entries returned by CSS coverage.
Why does CSS injected at runtime sometimes not show up?
Puppeteer documents that dynamically injected style tags without sourceURLs are not included in CSS coverage.
Should I collect one report per route or one across routes?
Use per-route reports when route-level evidence matters. Use resetOnNavigation: false when you intentionally want collection to accumulate across navigation, and record the route sequence either way.


