research/self-hosting-google-fonts
Self-hosting Google Fonts without a build plugin
Engineering Note · October 5, 2026 · 7 minute read · Performance · Web · Privacy
Self-hosting Google Fonts means downloading the font files once, serving them from your own domain, and pointing your @font-face rules at those local copies instead of at Google’s servers. You trade a runtime dependency on a third party for a one-time script and a folder of woff2 files.
Loading fonts from a third-party host is the default, and it is rarely examined. Every page view makes extra connections to another company’s servers, the page’s typography depends on their uptime, and visitors’ browsers announce themselves to a host you don’t control. None of that is dramatic. All of it is avoidable for roughly an afternoon of work.
What we moved
This site used two fonts globally and a different set per template page, 24 families in total. Each template had its own stylesheet import pointing at the font host, which meant the set of third-party requests depended on which page you visited.
How do you self-host Google Fonts with a script?
Google’s CSS endpoint returns different files depending on the browser asking. Request it with a current desktop browser user agent and you get woff2 files, split into one @font-face block per unicode range, each preceded by a comment naming the subset: latin, latin-ext, cyrillic, and so on.
Our script does four things for each family we use:
- Requests the CSS for exactly the weights we need.
- Keeps only the
latinblocks, since the site is English. - Downloads each woff2 file into
public/fonts/. - Rewrites the
srcto a local path and appends the block to one generated stylesheet.
One stylesheet, imported once from the base layout, replaces every per-template import.
Below is a runnable version. We executed it with Node v22.22.2 (global fetch, no dependencies) against seven families chosen for the demo, not the 24 on this site.
// selfhost.mjs - fetch Google Fonts CSS, keep latin blocks, dedupe by URL, download woff2.
import { mkdir, writeFile } from "node:fs/promises";
import { createHash } from "node:crypto";
const UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36";
const FAMILIES = [
{ name: "Inter", spec: "Inter:wght@400;500;600;700" },
{ name: "Lora", spec: "Lora:wght@400;500;600;700" },
{ name: "Roboto", spec: "Roboto:wght@400;500;700" },
{ name: "Merriweather", spec: "Merriweather:wght@400;700" },
{ name: "JetBrains Mono", spec: "JetBrains+Mono:wght@400;700" },
{ name: "DM Sans", spec: "DM+Sans:wght@400;500;700" },
{ name: "Lato", spec: "Lato:wght@400;700" },
];
await mkdir("out/fonts", { recursive: true });
const rows = [];
const fileByUrl = new Map(); // url -> local name
let css = "";
let totalBlocksAll = 0, latinBlocksAll = 0, naiveBytes = 0;
for (const fam of FAMILIES) {
const res = await fetch(`https://fonts.googleapis.com/css2?family=${fam.spec}&display=swap`, { headers: { "User-Agent": UA } });
const text = await res.text();
const blocks = [...text.matchAll(/\/\* ([\w-]+) \*\/\s*(@font-face \{[^}]*\})/g)].map(m => ({ subset: m[1], css: m[2] }));
const latin = blocks.filter(b => b.subset === "latin");
const urls = latin.map(b => b.css.match(/url\((https:[^)]+\.woff2)\)/)[1]);
totalBlocksAll += blocks.length; latinBlocksAll += latin.length;
let bytes = 0;
for (const b of latin) {
const url = b.css.match(/url\((https:[^)]+\.woff2)\)/)[1];
if (!fileByUrl.has(url)) { // the dedupe rule
const buf = Buffer.from(await (await fetch(url)).arrayBuffer());
const name = `${fam.name.toLowerCase().replace(/ /g, "-")}-${createHash("sha1").update(url).digest("hex").slice(0, 8)}.woff2`;
await writeFile(`out/fonts/${name}`, buf);
fileByUrl.set(url, { name, bytes: buf.length });
bytes += buf.length;
}
naiveBytes += fileByUrl.get(url).bytes;
css += b.css.replace(/url\([^)]+\)/, `url(/fonts/${fileByUrl.get(url).name})`) + "\n";
}
rows.push({ family: fam.name, allBlocks: blocks.length, latinBlocks: latin.length, distinctFiles: new Set(urls).size, kb: (bytes / 1024).toFixed(1) });
}
await writeFile("out/fonts.css", css);
console.table(rows);
const totalBytes = [...fileByUrl.values()].reduce((a, f) => a + f.bytes, 0);
console.log(`all-subset blocks: ${totalBlocksAll}`);
console.log(`latin blocks: ${latinBlocksAll}`);
console.log(`distinct latin files: ${fileByUrl.size}`);
console.log(`bytes if every block saved its own copy: ${naiveBytes}`);
console.log(`total bytes on disk: ${totalBytes} (${(totalBytes/1024).toFixed(1)} KiB)`);
What do the numbers look like?
This is the measured output of that script. Google re-versions its files over time, so your byte counts may differ slightly; the shape will not.
| Family | Blocks, all subsets | Latin blocks | Distinct latin files | KiB downloaded |
|---|---|---|---|---|
| Inter (400, 500, 600, 700) | 28 | 4 | 1 | 47.1 |
| Lora (400, 500, 600, 700) | 28 | 4 | 1 | 36.9 |
| Roboto (400, 500, 700) | 27 | 3 | 1 | 42.1 |
| Merriweather (400, 700) | 10 | 2 | 1 | 95.3 |
| JetBrains Mono (400, 700) | 12 | 2 | 1 | 30.7 |
| DM Sans (400, 500, 700) | 6 | 3 | 1 | 36.1 |
| Lato (400, 700) | 4 | 2 | 2 | 45.5 |
all-subset blocks: 115
latin blocks: 20
distinct latin files: 8
bytes if every block saved its own copy: 888960
total bytes on disk: 341712 (333.7 KiB)
Two things stand out. Keeping only latin cut 115 blocks to 20. Deduplicating by URL cut those 20 to 8 files, and a naive copy-per-block script would have written 888,960 bytes against our 341,712. Lato is the exception: it’s a static family, so each weight is its own file.
The dedupe rule that mattered
Variable fonts hide a trap. When you ask for weights 400, 500, 600, and 700 of a variable family, the CSS lists four blocks, but all four point at the same file. A naive script downloads it four times and stores four identical copies.
Deduplicating by source URL fixed it. The first block for a URL decides the file name, and later blocks reuse that name. For this site, 57 font-face blocks turned out to point at only 43 distinct files, about 780 KB in total for all 24 families, and the naive version would have stored the duplicates.
Why do you need a modern user agent?
The endpoint picks the file format from the User-Agent header. We asked for the same two Inter weights three ways with this shell script:
URL='https://fonts.googleapis.com/css2?family=Inter:wght@400;700'
CHROME='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36'
OLDFF='Mozilla/5.0 (Windows NT 6.1; rv:20.0) Gecko/20100101 Firefox/20.0'
report() {
css=$(cat)
printf '%-14s blocks=%s formats=%s subset_comments=%s\n' "$1" \
"$(echo "$css" | grep -c '@font-face')" \
"$(echo "$css" | grep -o "format('[a-z0-9]*')" | sort -u | tr '\n' ' ')" \
"$(echo "$css" | grep -c '^/\*')"
}
curl -sS "$URL" | report "curl default"
curl -sS -A "$CHROME" "$URL" | report "Chrome 124"
curl -sS -A "$OLDFF" "$URL" | report "Firefox 20"
curl default blocks=2 formats=format('truetype') subset_comments=0
Chrome 124 blocks=14 formats=format('woff2') subset_comments=14
Firefox 20 blocks=2 formats=format('woff') subset_comments=0
Without a browser user agent you get truetype files and no subset comments, which means no way to pick the latin blocks. Send a current Chrome string and you get woff2, which is what you want to ship.
Loading behavior
Declaring a face doesn’t download it. Browsers fetch a font file only when text on the page actually uses it, so putting every family in one global stylesheet costs a few kilobytes of CSS and nothing else. The exception worth handling is the typeface every page uses. We preload the two base faces in the document head so the first paint doesn’t wait for the stylesheet to be parsed before discovering them.
Every face uses font-display: swap, which shows fallback text immediately and swaps when the file arrives.
How do you verify nothing still calls the old host?
Moving fonts is easy to get subtly wrong. Two checks caught real problems for us.
Search the built output for the old host. After a build, grep the generated HTML and CSS for the third-party domain. Any hit is a template that still imports from it. We ran this against a two-file stand-in for a dist/ folder, once with a leftover import and once after removing it:
grep -rn "fonts.googleapis.com\|fonts.gstatic.com" dist; echo "exit=$?"
rm dist/a.html
grep -rn "fonts.googleapis.com\|fonts.gstatic.com" dist; echo "exit=$?"
dist/a.html:1:<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter">
exit=0
exit=1
Exit code 0 means a match, so a CI step should fail on 0 and pass on 1. The generated fonts.css from the script above contains zero occurrences of gstatic.
Render with the network closed. Our visual checks run pages in a browser that blocks every origin except the site itself. If the fonts render correctly there, they are being served locally, and the check can’t pass by accident. That check is part of our own test suite and is not reproduced in the script above.
When not to do this
Self-hosting is a trade, not a rule. A site with a rotating set of fonts chosen by non-developers is better served by the hosted service. A site with a stable set of typefaces and an owner who cares about performance, privacy, or simply not depending on someone else’s availability is a good candidate. The script is small enough that regenerating the files when the set changes is a two-minute task, and that’s the real test of whether the approach will hold up over time.
Related reading
- Website Speed Is a Product Feature, Not a Technical Detail
- The Case for Boring Web Architecture
- The Real Cost of Rented Platforms: Page Builders, Marketplaces and Lock-In
Frequently asked questions
How do I self-host Google Fonts without a build plugin?
Fetch the css2 URL for your families with a current desktop Chrome user agent, keep the latin @font-face blocks, download each distinct woff2 URL once, rewrite src to a local path, and write the blocks into one stylesheet. A script of about fifty lines does this. Then grep the build output for the old host to catch leftovers.
Why does the Google Fonts CSS change depending on the browser?
The endpoint chooses the file format from the user agent. In our test, a bare curl request returned two truetype blocks, an old Firefox user agent returned two woff blocks, and a Chrome user agent returned fourteen woff2 blocks split by unicode range, each labeled with a subset comment. Send a modern user agent to get woff2.
How many font files does a variable font need?
One file per subset and style, not one per weight. In our test, requesting four weights of Inter produced four latin blocks that all pointed at a single woff2 of 47.1 KiB. Deduplicating by source URL stores it once. Static families such as Lato still need one file per weight.
Do all self-hosted fonts in a global stylesheet download on every page?
No. A browser downloads a font file only when text on the page uses that face, so declaring many families in one stylesheet costs a few kilobytes of CSS. Preload only the faces every page uses, so the first paint does not wait for the stylesheet to be parsed before discovering them.
How do I confirm no page still loads fonts from Google?
After a build, grep the generated HTML and CSS for fonts.googleapis.com and fonts.gstatic.com. Any match is a template that still imports from the host. Then render pages in a browser that blocks every origin except your own; if the fonts still display, they are served locally.