Skip to Content
LoadingCSS Media Queries Analysis

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 @media blocks 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: true for <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-width is greater than the specified value (in px, em, or rem)
  • Excludes max-width only 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-width media 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.bytes shows 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

FeatureChromeEdgeFirefoxSafari
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.

Last updated on
MIT 2026 © Joan León | @nucliweb