CSS media queries analysis
Chrome 42+ Edge 14+ Firefox 39+ Safari 10.1+ Details
Analyze all @media rules in CSS stylesheets that target viewports bigger than a breakpoint (default: 768px). Narrower screens download and parse that CSS without using it. The snippet groups the rules by distinct query, the way CSS analytics tools such as Project Wallace (opens in a new tab) report at-rules, and breaks them down by stylesheet.
Snippet
Run it from an AI agent: the prompt uses the CLI to measure any URL and report the result.
// Analyze CSS @media rules for viewports bigger than a specified breakpoint
// Default minWidth = 768 (px), but you can customize it
async function analyzeCSSMediaQueries(minWidth = 768) {
// @shared ownRequestUrl
// The URL a snippet requests when it reads a file of the page itself (fetch, a HEAD request,
// new Image()). The fragment never reaches the server or the HTTP cache key, so the file still
// comes from the cache, but it stays in the name of the Resource Timing entry of the request,
// which is how isOwnRequest leaves these requests out of what the page loaded.
function ownRequestUrl(url) {
try {
const parsed = new URL(url, location.href);
if (!/^https?:$/.test(parsed.protocol)) return url;
parsed.hash = "webperf-snippets";
return parsed.href;
} catch {
return url;
}
}
// @end-shared ownRequestUrl
const SCRIPT = "CSS-Media-Queries-Analysis";
const MAX_ITEMS = 50;
const WARNING_BYTES = 50 * 1024;
const stylesheets = [...document.styleSheets];
const rules = []; // one per @media rule: { source, inline, mediaQuery, classes, properties, bytes }
let corsBlockedCount = 0;
// Helper to check if media query targets bigger than specified breakpoint
function isBiggerThanBreakpoint(mediaText) {
if (!mediaText) return false;
// Check for min-width greater than specified breakpoint
const minWidthMatch = mediaText.match(/min-width:\s*(\d+)(px|em|rem)/i);
if (minWidthMatch) {
const value = parseInt(minWidthMatch[1]);
const unit = minWidthMatch[2].toLowerCase();
if (unit === "px" && value > minWidth) return true;
if (unit === "em" && value > minWidth / 16) return true; // Convert to em
if (unit === "rem" && value > minWidth / 16) return true; // Convert to rem
}
// max-width only queries are for smaller viewports
return false;
}
// Classes and declarations inside the rule, not in its media query. A class starts with a
// letter, an underscore or a hyphen, so values such as 1.5rem do not count
function countClassesAndProperties(innerCss) {
const classMatches = innerCss.match(/\.-?[_a-zA-Z][\w-]*/g) || [];
const propertyMatches = innerCss.match(/[a-z-]+\s*:/g) || [];
return { classes: classMatches.length, properties: propertyMatches.length };
}
// Helper to calculate byte size
function getByteSize(text) {
return new Blob([text]).size;
}
// Helper to format bytes
// @shared formatBytes
function formatBytes(bytes) {
if (bytes === null || bytes === undefined || Number.isNaN(bytes)) return "-";
if (bytes === 0) return "0 B";
const units = ["B", "KB", "MB", "GB"];
const i = Math.max(0, Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1));
return (bytes / Math.pow(1024, i)).toFixed(1) + " " + units[i];
}
// @end-shared formatBytes
function addRule(source, inline, mediaQuery, fullCss, innerCss) {
if (!isBiggerThanBreakpoint(mediaQuery)) return;
rules.push({ source, inline, mediaQuery, ...countClassesAndProperties(innerCss), bytes: getByteSize(fullCss) });
}
// Parse CSS text to find @media rules, for stylesheets read with fetch
function parseMediaQueriesFromCSS(cssText, source) {
const mediaRegex = /@media\s*([^{]+)\{((?:[^{}]|\{[^{}]*\})*)\}/g;
let match;
while ((match = mediaRegex.exec(cssText)) !== null) {
addRule(source, false, match[1].trim(), match[0], match[2]);
}
}
// Process stylesheets
for (let sheetIndex = 0; sheetIndex < stylesheets.length; sheetIndex++) {
const sheet = stylesheets[sheetIndex];
const inline = !sheet.href;
const source = sheet.href || `<style> tag #${sheetIndex}`;
try {
const sheetRules = sheet.cssRules || sheet.rules;
if (sheetRules) {
[...sheetRules].forEach((rule) => {
if (rule.type === CSSRule.MEDIA_RULE) {
const inner = [...rule.cssRules].map((r) => r.cssText).join("");
addRule(source, inline, rule.media.mediaText, rule.cssText, inner);
}
});
}
} catch {
// Cross-origin stylesheet without CORS: try to fetch it
if (sheet.href) {
try {
const response = await fetch(ownRequestUrl(sheet.href));
parseMediaQueriesFromCSS(await response.text(), sheet.href);
} catch {
corsBlockedCount++;
}
}
}
}
// --- One item per distinct query ------------------------------------------------------
const byQuery = new Map();
for (const r of rules) {
const q = byQuery.get(r.mediaQuery) ?? { mediaQuery: r.mediaQuery, rules: 0, sources: new Set(), classes: 0, properties: 0, bytes: 0 };
q.rules++;
q.sources.add(r.source);
q.classes += r.classes;
q.properties += r.properties;
q.bytes += r.bytes;
byQuery.set(r.mediaQuery, q);
}
const queries = [...byQuery.values()]
.map(({ sources, ...q }) => ({
mediaQuery: q.mediaQuery,
rules: q.rules,
files: sources.size,
classes: q.classes,
properties: q.properties,
bytes: q.bytes,
}))
.sort((a, b) => b.bytes - a.bytes);
const bySourceMap = new Map();
for (const r of rules) {
const s = bySourceMap.get(r.source) ?? { source: r.source, inline: r.inline, rules: 0, bytes: 0 };
s.rules++;
s.bytes += r.bytes;
bySourceMap.set(r.source, s);
}
const bySource = [...bySourceMap.values()];
const totalBytes = rules.reduce((sum, r) => sum + r.bytes, 0);
const uniquenessRatio = rules.length ? Math.round((queries.length / rules.length) * 1000) / 1000 : 0;
// --- Issues ----------------------------------------------------------------------------
const issues = [];
if (rules.length === 0) {
issues.push({ severity: "info", message: `No @media rules for viewports wider than ${minWidth}px` });
} else {
issues.push({
severity: totalBytes > WARNING_BYTES ? "warning" : "info",
message: `${rules.length} @media rules for viewports wider than ${minWidth}px add ${formatBytes(totalBytes)} of CSS that narrower screens download and parse without using. Move them to a stylesheet with a media attribute, such as <link rel="stylesheet" href="wide.css" media="(min-width: ${minWidth + 1}px)">, which narrower screens fetch at low priority without blocking rendering`,
});
if (rules.length >= 20 && uniquenessRatio < 0.1) {
issues.push({
severity: "info",
message: `${queries.length} distinct queries repeat across ${rules.length} @media blocks, the pattern of component-scoped or compiled CSS. Grouping the rules of each query under one block shortens the stylesheets`,
});
}
}
if (corsBlockedCount > 0) {
issues.push({ severity: "info", message: `${corsBlockedCount} cross-origin stylesheet(s) could not be read, so their @media rules are not counted` });
}
// --- Console output --------------------------------------------------------------------
console.group(`CSS Media Queries Analysis (min-width > ${minWidth}px)`);
console.log(`@media rules: ${rules.length} | distinct queries: ${queries.length} | size: ${formatBytes(totalBytes)}`);
if (queries.length > 0) {
console.table(
queries.map((q) => ({ Query: q.mediaQuery, Rules: q.rules, Files: q.files, Classes: q.classes, Properties: q.properties, Size: formatBytes(q.bytes) }))
);
console.table(bySource.map((s) => ({ Source: s.source, Rules: s.rules, Size: formatBytes(s.bytes) })));
}
issues.forEach((i) => console.log(`[${i.severity}] ${i.message}`));
console.groupEnd();
return {
script: SCRIPT,
status: "ok",
count: queries.length,
details: {
minWidth,
rules: rules.length,
totalUnique: queries.length,
uniquenessRatio,
classes: rules.reduce((sum, r) => sum + r.classes, 0),
properties: rules.reduce((sum, r) => sum + r.properties, 0),
bytes: totalBytes,
bySource: bySource.slice(0, MAX_ITEMS),
corsBlockedCount,
},
items: queries.slice(0, MAX_ITEMS),
issues,
};
}
// Run with default breakpoint (768px)
analyzeCSSMediaQueries();
// Or customize the breakpoint:
// analyzeCSSMediaQueries(1024); // for desktop
// analyzeCSSMediaQueries(480); // for small tablets
Understanding the results
Each item is a distinct media query, largest first, with:
- mediaQuery: the condition, such as
(min-width: 1024px) - rules: how many
@mediablocks use it - files: in how many stylesheets
- classes and properties: class selectors and declarations inside those blocks
- bytes: size of those blocks, measured on the CSS as the browser serializes it, before compression
details sums them up:
- rules, totalUnique and uniquenessRatio: how many blocks, how many distinct queries, and the ratio between both. A low ratio means the same few queries repeat across many blocks, the pattern of component-scoped or compiled CSS
- classes, properties and bytes: totals of all the blocks
- bySource: rules and bytes of each stylesheet, with
inline: truefor<style>tags - corsBlockedCount: cross-origin stylesheets that could not be read
The issues give the total bytes, as a warning above 50 KB, and point to moving these rules to a stylesheet with a media attribute.
Customizing the breakpoint
By default, the snippet uses 768px as the mobile breakpoint, but you can customize it:
// Analyze media queries bigger than 1024px (desktop)
analyzeCSSMediaQueries(1024);
// Analyze media queries bigger than 480px (small tablets)
analyzeCSSMediaQueries(480);
// Analyze media queries bigger than 1440px (large desktop)
analyzeCSSMediaQueries(1440);Breakpoint detection
The snippet considers a media query “bigger than the breakpoint” when:
min-widthis greater than the specified value (in px, em, or rem)- Excludes
max-widthonly queries (typically for smaller viewports) - Automatically converts em/rem units (assuming 16px base font size)
Solutions & optimization strategies
Understanding how much CSS is dedicated to larger viewports helps identify optimization opportunities:
1. split CSS by media query
- Separate mobile and desktop CSS into different files
- Load desktop CSS with a media query:
<link media="(min-width: 768px)" href="desktop.css"> - Reduces initial CSS parse time on mobile devices
- Reduces style recalculation work (browser doesn’t need to process desktop styles)
2. inline critical above-the-fold CSS
- Move critical mobile CSS inline in
<style>tags - Defer non-critical and desktop CSS loading
- Improves First Contentful Paint (FCP) and Largest Contentful Paint (LCP) on mobile
3. use CSS-in-JS or CSS modules
- Modern bundlers can split CSS by route/component
- Only load CSS when needed (code splitting)
- Reduces unused CSS on initial load
4. consider mobile-first approach
- Write base styles for mobile without media queries
- Use
min-widthmedia queries for progressive enhancement - Reduces CSS overhead on mobile devices
5. audit and remove unused desktop CSS
- If desktop media queries contain excessive classes/properties
- Use tools like PurgeCSS or UnCSS to remove unused selectors
- Reduces overall CSS bundle size
6. measure the impact
details.bytesshows how many bytes of CSS narrower screens get without using them- Compare this against your total CSS size to understand the optimization opportunity
- Even 20-50 KB savings can improve mobile performance significantly
Browser support
| Feature | Chrome | Edge | Firefox | Safari |
|---|---|---|---|---|
| CSSStyleSheet.cssRules (opens in a new tab) | 1 | 12 | 1 | 1 |
| fetch (opens in a new tab) | 42 | 14 | 39 | 10.1 |
| All of the above | 42 | 14 | 39 | 10.1 |
Source: MDN browser compatibility data (opens in a new tab), version 8.1.4.
Further reading
For an in-depth understanding of CSS and network performance optimization strategies, check out this excellent article:
CSS and Network Performance (opens in a new tab) by Harry Roberts (opens in a new tab) (CSS Wizardry)
This article covers:
- How CSS blocks rendering and impacts performance
- Strategies for optimizing CSS delivery
- Using media queries to conditionally load CSS
- Measuring and improving CSS performance
Note
Cross-origin stylesheets cannot be read through cssRules. The snippet then fetches them, which works when the server sends CORS headers; the ones it still cannot read are counted in details.corsBlockedCount and reported as an issue.