
Open DevTools on most WordPress sites, filter the Network panel to fonts, and reload. You will usually see two hosts: fonts.googleapis.com for a stylesheet, then fonts.gstatic.com for the actual font files. Neither one is your server.
That single fact creates two separate problems. The first is a render-blocking stylesheet on someone else’s server, sitting in front of your first paint. The second is that every visitor’s IP address, browser version, and the page they are reading get sent to Google before they have agreed to anything. One change fixes both, and it takes less time than most people expect.
What a Google Fonts Request Actually Sends
The standard embed is a stylesheet link:
<link rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap">
Because it is a stylesheet, the browser treats it as render-blocking. It has to resolve a new hostname, open a new TLS connection, fetch the CSS, parse it, and only then does it know which .woff2 files it needs from a second hostname. That is two third-party origins in the critical path before your chosen typeface can paint.
The data side is documented by Google itself. Its Fonts FAQ states that requests to the Web API include “the IP address used by the respective user to access the Internet,” the requested URL on Google’s server, and HTTP headers including the user agent and “the referer (i.e. the webpage on which the Google font is to be displayed).”
Two nearby sentences get quoted out of context. Google says the API “does not set or log cookies” and that it does “not use any information collected by Google Fonts to create profiles of end users or for targeted advertising.” Both are true, and neither means no personal data is transferred. An IP address is personal data under EU law, and it leaves the visitor’s browser either way.
There is also a performance claim that no longer holds. Google’s FAQ describes the original appeal of the service as letting “your browser cache commonly used fonts across all the websites that used the API” and then says plainly: “This is no longer true.” Cross-site caching is gone, so the old argument that Google Fonts is faster because the visitor already has the file is dead. What remains is a per-site download from someone else’s server.
The Munich Ruling, Without the Exaggeration
In January 2022, the Regional Court of Munich I ruled in case 3 O 17493/20 on a website that loaded Google Fonts from Google’s servers. The visitor’s IP address reached Google without consent. The court found no lawful basis for that transfer, awarded the plaintiff €100 in damages, and issued an injunction. The judgment threatened a fine of up to €250,000 per violation for continued use, as The Register reported at the time.
The reasoning matters more than the amount. The operator argued legitimate interest. The court rejected it because the same typography could be delivered from the site’s own server at no cost. Where a privacy-neutral alternative exists and is free, the privacy-invasive version is not “necessary” in the sense GDPR requires.
What the ruling does not mean:
- It is not an EU-wide ban on Google Fonts. It is one German regional court decision, and the font files themselves are open source and free to use.
- It does not make a cookie banner a fix. Fonts load on page load. By the time a consent banner appears, the request has already happened.
- The EU-US Data Privacy Framework does not resolve it. An adequacy decision makes a transfer lawful under Article 45. It does not supply the Article 6 basis for the processing itself, which is the point the court decided.
Self-hosting removes this specific third-party request. It does not fix your analytics, embeds, payment scripts, or anything else that talks to a server you do not control. If you are updating your privacy policy at the same time, treat the font change as one line item, not the whole project.
Four Ways to Do It in WordPress
1. The Font Library, for block themes. Since WordPress 6.5, block themes have a built-in font manager: Appearance → Editor → Styles → Typography → Manage Fonts → Install Fonts. You grant the site access to the Google Fonts API, pick a family and weights, and WordPress downloads the files to your own server. The access grant is your server calling Google once during installation; visitors never touch it. Fonts land in the uploads folder’s fonts subdirectory, and the font_dir filter can move them elsewhere. If you are already working with theme.json and global styles, this is the shortest path — our guide to global styles for block themes covers how the typography settings fit together.
2. Manual @font-face, for classic themes. You control the files, the subsets, the cache headers, and the load order. It is more work and the result is easier to audit. This is the method the rest of this article builds.
3. A plugin. OMGF, Local Google Fonts, and the font modules in caching plugins all do roughly the same thing: intercept the Google Fonts request, download the files, and rewrite the CSS. They are fine when you need this done across a site you did not build. Verify the output anyway, because some of them inline the CSS or change the load order in ways that affect first paint.
4. A theme or builder switch. Many themes and page builders ship a “host fonts locally” option. Turn it on, then check the Network panel. Those switches usually cover the theme’s own typography, not a plugin that registers its own Google Fonts handle.
Whichever route you take, the verification is identical, and it comes later in this article.
Keep the Subsets: The Download Does Not Have to Be Heavy
Here is where most self-hosting guides go wrong, and it is easy to reproduce.
Ask Google’s CSS API for a font with a normal command-line tool and you get this:
curl -s "https://fonts.googleapis.com/css2?family=Inter:wght@400&display=swap"
The response is a single @font-face pointing at a .ttf file with no unicode-range. That is the whole character set for that weight in one file. Download it, upload it, and you have made your page heavier than it was, which is exactly the drawback Google’s FAQ warns about when it says self-hosting increases file size.
Send a browser user agent instead and the same URL returns something different: seven @font-face blocks for that single weight, one per subset, each pointing at a separate .woff2 with its own unicode-range. Ask for two weights and you get fourteen. The browser downloads only the subsets the page actually uses. A page in English fetches the latin file, not the Cyrillic and Greek ones.
That is the mechanism the unicode-range descriptor exists for. Keep the blocks and you keep the optimisation Google was doing for you.
This script does it in one pass. It fetches the CSS as a modern browser, keeps the latin subsets, downloads each file, and rewrites the URLs:
import os, re, urllib.request
UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 Chrome/131.0.0.0 Safari/537.36")
CSS_URL = "https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap"
KEEP = {"latin", "latin-ext"}
def get(url, binary=False):
req = urllib.request.Request(url, headers={"User-Agent": UA})
data = urllib.request.urlopen(req).read()
return data if binary else data.decode()
css = get(CSS_URL)
blocks = re.findall(r"/\*\s*([a-z\-]+)\s*\*/\s*(@font-face\s*\{.*?\})", css, re.S)
os.makedirs("fonts", exist_ok=True)
out = []
for subset, block in blocks:
if subset not in KEEP:
continue
src = re.search(r"url\((https://[^)]+\.woff2)\)", block).group(1)
name = src.rsplit("/", 1)[-1]
path = os.path.join("fonts", name)
if not os.path.exists(path):
open(path, "wb").write(get(src, binary=True))
out.append(block.replace(src, f"fonts/{name}"))
open("fonts.css", "w", encoding="utf-8").write("\n".join(out))
print(f"{len(out)} blocks, {len(os.listdir('fonts'))} files")
Run it in an empty folder, then upload fonts/ and fonts.css to your child theme. For non-Latin sites, add the subset names you need (cyrillic, greek, vietnamese, devanagari) to KEEP. The filenames keep Google’s version hash, so a font update produces a new filename instead of a stale cache entry.
Then enqueue the stylesheet:
add_action( 'wp_enqueue_scripts', function () {
wp_enqueue_style(
'theme-fonts',
get_stylesheet_directory_uri() . '/fonts.css',
array(),
'1.0'
);
} );
If you want to avoid the extra request, paste the same rules at the top of your child theme’s style.css instead. Keep the fonts/ folder beside it so the relative URLs still resolve.
The @font-face Block That Does Not Shift Layout
Three decisions determine whether self-hosting feels faster or just looks broken for a moment.
font-display. swap shows fallback text immediately and swaps in the custom font when it arrives. That avoids invisible text, but the swap can move content. optional lets the browser skip the custom font entirely on a slow first load, which prevents the shift but means some visitors see the fallback until they have the file cached. For body text, optional is often the better trade. For a heading that defines the brand, swap is usually the right call.
Fallback metrics. If your fallback is Arial and your font is Inter, the two have different character widths, so the swap moves lines. The fix is a second @font-face that redefines the local fallback with adjusted metrics:
@font-face {
font-family: "Inter Fallback";
src: local("Arial");
size-adjust: 100%; /* replace with a measured value */
ascent-override: normal;
descent-override: normal;
line-gap-override: normal;
}
Use that family between the real font and the system stack: font-family: Inter, "Inter Fallback", Arial, sans-serif;. The four numbers come from measuring the two font files. A metric-matching tool will generate them; guessing produces a different shift, not less of one.
Preload, but only one file. Preload makes a font discoverable before the CSS is parsed. It also bypasses the browser’s own decisions, and web.dev’s font guidance is direct about the cost: preload “ignores unicode-range declarations,” so a preloaded subset can be downloaded even when the page never uses it, and it takes bandwidth from other resources.
Add this to the head, using the filename the script produced:
<link rel="preload"
href="/wp-content/themes/child-theme/fonts/your-latin-400.woff2"
as="font" type="font/woff2" crossorigin>
The crossorigin attribute is not optional. Fonts are fetched in CORS mode even from your own domain, and a preload without it is treated as a different request, so the browser downloads the file twice.
Preload the file used by your largest above-the-fold text, and nothing else. If your CSS already loads early and the font is one subset, preload often adds little.
Cache Headers and the Rest of the Cleanup
A font file is immutable, and the hashed filename changes whenever the font version does. Give it a year and stop asking the browser to check:
<IfModule mod_headers.c>
<FilesMatch "\.woff2$">
Header set Cache-Control "public, max-age=31536000, immutable"
</FilesMatch>
</IfModule>
On nginx, the equivalent goes in a location ~* \.woff2$ block. Two related details:
- Do not gzip WOFF2. The format is already Brotli-compressed. Re-compressing it burns CPU for no transfer saving.
- CORS only matters across origins. Same-origin fonts need no
Access-Control-Allow-Originheader. If you serve fonts from a CDN, you do need it, and you should confirm the CDN is sending the font with the right content type. Our comparison of CDN services covers what to check.
Then remove the old path completely: the <link> in the header, any @import at the top of a stylesheet, the theme’s Google Fonts handle, and the plugin or builder setting that re-adds it. Leaving one behind means the browser downloads both versions, which is slower than doing nothing.
If you use a caching plugin, purge it after the swap. Cached HTML still contains the old <link>, so the change appears not to work. If you do not have one yet, the cache plugin comparison covers the options.
How to Verify in Five Minutes
Network panel. Filter by font, tick “Disable cache”, reload. You should see .woff2 requests to your own domain and nothing to fonts.gstatic.com. Check a post, a page, the homepage, and one archive.
Rendered HTML. View source and search for fonts.googleapis.com. Then check whether it comes from the theme or from a plugin:
curl -s https://your-site.com/ | grep -o "fonts\.[a-z]*\.com" | sort -u
Headers. Confirm the cache header actually arrives, rather than assuming the server config applied:
curl -sI https://your-site.com/wp-content/themes/child-theme/fonts/your-latin-400.woff2 \
| grep -i -E "cache-control|content-type"
You want font/woff2 and a long max-age. If the content type is text/html, the path is wrong and WordPress is serving a 404 page.
Field data. If the point was Core Web Vitals, wait for real-user data before claiming a win. A lab score on your laptop is not evidence that LCP improved for visitors. The measurement side is covered in Performance-First WordPress.
When Self-Hosting Is Slower
Self-hosting is not automatically faster, and pretending otherwise leads to bad decisions.
Google’s edge network is probably closer to your visitor than your origin server is. Web.dev’s guidance says to compare transfer time including connection setup, and warns that a self-hosted font on a slow server without HTTP/2 can lose to the third-party version. If you are on shared hosting with a slow time to first byte, moving the font onto that server does not improve anything.
What self-hosting reliably removes is a render-blocking third-party stylesheet and one extra origin in the critical path. Whether that shows up as a faster LCP depends on where your server sits and how fast it serves static files.
If your server is the bottleneck, fix the server, or use a system font stack and skip web fonts entirely for body text. That is the fastest option available and it has no privacy question attached. If fonts are not the main bottleneck, the other requests on the page are covered in ways to increase WordPress speed.
FAQ
Is using Google Fonts illegal?
No. The fonts are open source and free to use commercially. What the Munich court found unlawful was transmitting a visitor’s IP address to Google without a lawful basis when the same result could be achieved from the site’s own server.
Do I need a cookie banner for Google Fonts?
A banner does not fix the problem if the font request fires before consent, which it does on a normal page load. The two real options are self-hosting or a consent tool that blocks the request until the visitor opts in, with a fallback font in the meantime.
Does self-hosting Google Fonts improve Core Web Vitals?
Not automatically. It removes one render-blocking request and one third-party origin. The gain depends on your server’s speed and the visitor’s distance from it, so measure with field data rather than assuming.
Where does WordPress store fonts installed from the Font Library?
In the fonts subdirectory of your uploads folder, so backups that cover uploads include them automatically. The font_dir filter can point them somewhere else if your host restricts that directory.
My fonts still load from fonts.gstatic.com. Why?
Something is still printing the old stylesheet. Common sources are a leftover <link> in the header, an @import in a theme stylesheet, a page builder’s typography module, or cached HTML in a caching plugin. Search the rendered page, not just the theme files.
Should I self-host a variable font or separate weights?
A variable font covers a weight range in one file, which usually means fewer requests. It can also be larger than a single static weight, so the answer depends on how many weights you actually use. Compare the real file sizes for your family before deciding.
Can I serve self-hosted fonts from a CDN?
Yes, with two conditions: send Access-Control-Allow-Origin, because the font is now cross-origin, and keep the long cache headers. If the CDN is outside the EU, the transfer question returns in a different form, so treat it as a separate decision.