Skip to Main content

Preload Google Fonts Before the CSS Waterfall Starts

Google Fonts usually arrive through a slow little relay race: your page loads CSS, that CSS reveals another stylesheet, and only then do the real font files appear. This script cuts into that chain so the browser can discover the important font URLs much earlier.

Google Fonts are convenient, but the default loading path is not especially direct.

In the common setup, the browser has to discover the font files in stages. Your HTML loads the page CSS, the CSS references Google Fonts CSS, and that secondary stylesheet finally points at the actual WOFF2 files on fonts.gstatic.com. By the time the browser knows what to fetch, your text may already have spent some time waiting for the right font files to show up.

That delay is not always catastrophic, but it is often unnecessary.

The script shown below takes a more direct route. It fetches the Google Fonts CSS during build time, saves the @font-face rules into _font.scss, extracts the available WOFF2 URLs into _google-fonts.json, and gives developers a stable key they can use to add targeted preload links directly in the document head.

That last part is the useful trick. Preconnect is helpful, but preload is the point where the browser stops being told "you may need something from Google later" and starts being told "fetch this exact font file now".

Here is the actual script embedded on this page:

import fs from 'fs';
import path from 'path';
import css from 'css';
import { fileURLToPath } from 'url';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const googleFontsCssUrl =
  'https://fonts.googleapis.com/css2?family=Noto+Sans+Math&family=Poppins:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap';
const outputScssPath = '_font.scss';
const outputJsonPath = '_google-fonts.json';

const extractGoogleFont = (fontFace: css.FontFace, comment: string): [string | null, string | null] => {
  let name: string | null = null;
  let fontStyle: string | null = null;
  let fontWeight: string | null = null;
  let url: string | null = null;

  if (fontFace.declarations) {
    fontFace.declarations.forEach((declaration) => {
      if (declaration.type !== 'declaration') {
        return;
      }

      if (declaration.property === 'font-family' && declaration.value) {
        name = declaration.value.replace(/['"]/g, '').trim();
      }

      if (declaration.property === 'font-style' && declaration.value) {
        fontStyle = declaration.value.trim();
      }

      if (declaration.property === 'font-weight' && declaration.value) {
        fontWeight = declaration.value.trim();
      }

      if (declaration.property === 'src' && declaration.value) {
        const match = declaration.value.match(/url\(['"]?(https:\/\/[^'")]+\.woff2)['"]?\)/);
        if (match && match[1]) {
          url = match[1].trim();
        }
      }
    });
  }

  const combinedName = [name, comment, fontStyle, fontWeight].filter(Boolean).join(';').trim();
  return [combinedName, url];
};

const processRules = (rules: Array<css.Rule | css.Comment | css.AtRule>): Record<string, string> | null => {
  const googleFonts: Record<string, string> = {};
  let count = 0;

  for (let i = 0; i < rules.length - 1; i++) {
    if (rules[i].type === 'comment' && rules[i + 1].type === 'font-face') {
      const comment = (rules[i] as css.Comment).comment?.trim();

      if (comment) {
        const [name, url] = extractGoogleFont(rules[i + 1] as css.FontFace, comment);
        if (name && url) {
          googleFonts[name] = url;
          count++;
        }
      }

      i++;
    }
  }

  return count > 0 ? googleFonts : null;
};

const extractGoogleFonts = async (url: string) => {
  const response = await fetch(url, {
    headers: {
      'sec-ch-ua': '"Google Chrome";v="137", "Chromium";v="137", "Not/A)Brand";v="24"',
      'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36',
    },
  });

  if (!response.ok) {
    throw new Error(`Failed to fetch CSS from ${url}: ${response.statusText}`);
  }

  const cssText = await response.text();

  const obj = css.parse(cssText);
  const rules = obj.stylesheet?.rules;

  if (!rules) {
    console.warn(`No rules found in the CSS from ${url}`);
    return {};
  }

  const fonts = processRules(rules);

  if (!fonts) {
    console.warn(`No Google Fonts found in the CSS from ${url}`);
    return {};
  } else {
    const cssFontsPath = path.join(__dirname, outputScssPath);
    fs.writeFileSync(cssFontsPath, cssText, 'utf8');

    const cssFontsInfoPath = path.join(__dirname, outputJsonPath);
    fs.writeFileSync(cssFontsInfoPath, JSON.stringify(fonts, null, 2), 'utf8');
  }
};

await extractGoogleFonts(googleFontsCssUrl);

The slow path most sites start with

When Google Fonts are used in the usual way, the request chain looks something like this:

Document site CSS Google Fonts CSS font file URLs WOFF2 downloads

or:

Document Google Fonts CSS font file URLs WOFF2 downloads

That means the browser cannot request the actual font file until it has already completed several earlier steps.

If the font is used in visible text near the top of the page, that extra discovery chain can contribute to:

  • Later text rendering.
  • More visible font swapping.
  • Layout movement when fallback and final fonts do not match closely.
  • Worse Largest Contentful Paint when the hero text depends on the font.

This is why preconnect alone is only a partial improvement. It warms up the connection, but it does not tell the browser which WOFF2 file to fetch.

What the script does during build

The script embedded above reads a googleFontsCssUrl constant from the top of the file, downloads the CSS response from Google Fonts, parses each @font-face block, and keeps two different outputs. The source URL and both output paths are configurable at the top of the script, and the defaults now point to the Noto Sans Math and Poppins stylesheet, _font.scss, and _google-fonts.json.

The _font.scss file

The first output is _font.scss by default.

This file contains the Google Fonts CSS as-is, including the @font-face declarations. In the intended setup, this SCSS file is included in the site's base stylesheet, so the browser still has the normal font definitions it needs during rendering.

That means preload is an enhancement layer, not a replacement for CSS.

If a page does not add any preload links, the fonts can still load through the standard @font-face path.

If your project prefers a different location or naming scheme, you can change the SCSS output path in the constants at the top of the script.

The _google-fonts.json file

The second output is _google-fonts.json by default.

This file maps a readable key to the exact WOFF2 URL extracted from Google Fonts.

Like the SCSS output, this JSON path is configurable at the top of the script.

The keys are built from four parts:

  • Font family.
  • The comment that appears before the @font-face block in Google's CSS, usually the subset such as latin.
  • Font style.
  • Font weight.

That produces keys like this:

{
  "Poppins;latin;normal;400": "https://fonts.gstatic.com/s/poppins/v23/pxiEyp8kv8JHgFVrJJfecnFHGPc.woff2",
  "Poppins;latin;normal;600": "https://fonts.gstatic.com/s/poppins/v23/pxiByp8kv8JHgFVrLEj6Z1xlFQ.woff2"
}

Now the application has something much more useful than a generic Google Fonts stylesheet URL. It has a direct lookup table for individual font files.

Why this is faster than waiting for Google Fonts CSS

Once the JSON file exists, a developer can read the URL by key and emit a preload link straight into the page head:

<link
  rel="preload"
  as="font"
  type="font/woff2"
  crossorigin
  href="https://fonts.gstatic.com/s/poppins/v23/pxiByp8kv8JHgFVrLEj6Z1xlFQ.woff2">

That changes the discovery flow into something closer to this:

Document -> preload font URL immediately
Document -> site CSS with @font-face rules

The browser no longer needs to wait for the Google Fonts stylesheet to reveal the font file URL. It already knows the destination.

That can reduce the time to first useful text paint for font-dependent content, especially when the preloaded font is used above the fold.

It can also reduce the extra discovery steps where the browser first learns which host to contact, then which stylesheet to request, and only then which file to fetch.

If your server or edge platform supports HTTP 103 Early Hints, you can sometimes go one step further.

Instead of waiting for the final HTML response to deliver a <link rel="preload"> tag in the document head, the server can send a Link header early and let the browser begin fetching the font before the main response body is ready.

That can look like this:

Link: <https://fonts.gstatic.com/s/poppins/v23/pxiByp8kv8JHgFVrLEj6Z1xlFQ.woff2>; rel=preload; as=font; type="font/woff2"; crossorigin

In practice, this can be useful when you already know which font file a route will need and you want to start that request as early as possible.

The trade-off is that HTTP 103 support still depends on your full delivery chain: browser, CDN, reverse proxy, hosting platform, and application server. If one part of that chain ignores or strips Early Hints, the optimisation may not do much.

So the usual order of preference is fairly simple:

  • Start with normal preload links in the document head.
  • Use the generated _google-fonts.json file to make those links precise.
  • Consider HTTP 103 and Link headers only when your platform supports them well and you want to push the same preload signal even earlier.

Why keep both files instead of only preloading everything

Because preloading every available font file would consume bandwidth that many pages may never need.

The SCSS file and the JSON file solve two different problems:

  • _font.scss keeps the full set of @font-face declarations available to the site.
  • _google-fonts.json lets developers preload only the font files that are genuinely common and important.

That separation matters.

Most sites use a small subset of their font variants on the critical path. Maybe the home page needs 400 and 600, while some long-tail page later uses 300 italic once in a decorative quote that nobody should preload on every visit.

Preload is usually best used selectively.

A practical workflow

The intended workflow is straightforward:

  1. Update the googleFontsCssUrl constant at the top of the script if you want a different Google Fonts stylesheet.
  2. Run the build script so it fetches the stylesheet and generates both output files.
  3. Include _font.scss in the site's base style bundle, or change the configured path if your project wants a different destination.
  4. Read _google-fonts.json in your application code, or update the configured JSON path to match your project structure.
  5. Pick the small set of font keys used in high-visibility areas.
  6. Render preload links for those URLs directly in the document head.

That first step is intentionally plain: no Vite environment variable is required. If you copy a Google Fonts <link> tag from the browser, use the URL from its href attribute as the value of googleFontsCssUrl.

In pseudocode, the selection step looks like this:

const fonts = googleFontsMap;

const preloadFonts = [
  fonts['Poppins;latin;normal;400'],
  fonts['Poppins;latin;normal;600'],
].filter(Boolean);

And those values are the ones you turn into <link rel="preload" ...> tags.

What this approach does not magically solve

It is a useful optimisation, but it is still worth being careful about how much you preload.

Be selective.

Good preload candidates usually share three traits:

  • They are used in content visible immediately on page load.
  • They appear on many high-traffic pages.
  • They meaningfully affect perceived rendering quality or Largest Contentful Paint.

Poor preload candidates usually look like this:

  • Rare font variants.
  • Decorative fonts used below the fold.
  • Fonts needed only after user interaction.
  • Entire font families loaded "just in case".

Also worth noting: these URLs still point to Google-hosted font files, not self-hosted assets. That is not automatically a problem. Popular Google Fonts may already be cached in the browser from previous visits to other sites. Still, the main benefit here is earlier discovery, not ownership.

The real benefit in one sentence

This script moves font URL discovery from runtime discovery to build-time certainty.

That is why it works.

The browser still gets the standard @font-face rules through base CSS, but developers also get a clean lookup table for exact font files, which makes it possible to preload the few variants that actually influence first render.

If you use Google Fonts and care about above-the-fold text performance, this is one of those small build steps that can remove a meaningful amount of unnecessary waiting.