Dropping a Japanese Web Font Cut FCP from 4.1s to 1.7s; Half My Explanation Failed a Minimal Repro
Mobile FCP on PageSpeed fell from 4.1s to 1.7s once I stopped serving a Japanese web font. Self-hosting made it worse, and the render-blocking story did not reproduce.
When I opened the mobile result in PageSpeed Insights, the article page showed an FCP of 4.1 seconds. The top page showed an FCP of 7.7 seconds and an LCP of 9.5 seconds.
I suspected images first. I work with wallpaper images every day, so heavy files feel like familiar territory, and I assumed the LCP element would be one of them. It was text. What was slow was the font that draws that text.
The short version: once I stopped serving a Japanese web font, the numbers moved a lot. But my explanation of why they moved is only half confirmed.
Below I keep three things apart: the change that worked, the change that backfired, and the explanation I could not reproduce.
The numbers pointed at text, not images
The sites I run as Dolice loaded Noto Sans JP from Google Fonts for body text, in four weights: 300, 400, 500, and 700.
Page
FCP
LCP
Article page (before)
4.1 s
—
Top page (before)
7.7 s
9.5 s
PSI's mobile profile assumes a slow CPU and a slow connection. It is harsher than a real phone, which is exactly why it shows what the first paint is waiting for.
Once the LCP element turned out to be text, my search moved from image optimization to font delivery.
A 121KB stylesheet with 496 @font-face rules
I fetched the Google Fonts CSS with a Chrome user agent and measured it. The CSS differs per browser, so always send a realistic UA.
UA="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36"URL="https://fonts.googleapis.com/css2?family=Noto+Sans+JP:wght@300;400;500;700"# Transfer size (gzip)curl -s -A "$UA" -H "Accept-Encoding: gzip" -o /dev/null -w "gzip transfer=%{size_download}B\n" "$URL"# Expanded size and the number of @font-face rulescurl -s -A "$UA" -H "Accept-Encoding: identity" "$URL" | tee /tmp/css2.txt | wc -cgrep -c "@font-face" /tmp/css2.txt
I re-ran this on the day I wrote this article:
Item
Value
Transfer size (gzip)
121,308 bytes (about 121KB)
Expanded size
447,832 bytes (about 448KB)
@font-face rules
496 (4 weights × 124 subsets)
A Japanese font has too many glyphs for one file, so Google Fonts splits it with unicode-range. The browser only downloads the woff2 files that contain the characters on the page. But to decide which subsets it needs, it must first read all 496 declarations.
If you carry over the intuition from Latin fonts, where the CSS is a few kilobytes, your estimate misses by a wide margin.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦You can now check an external font stylesheet's transfer size and @font-face count before blaming images when mobile FCP is slow
✦You can now compare compressed size and FCP side by side, so a self-hosting change does not quietly make things worse
✦You can now run a few dozen lines of repro code against your own explanation, such as render-blocking, and verify it again after the fix
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
If the external host is slow, serve it from your own domain. That was my next thought, so I switched to next/font/google, which fetches the font at build time and serves it from the same origin.
It went the wrong way. The generated CSS was 375KB (33KB with Brotli), and FCP got worse, at 6.8 seconds.
The subset declarations had not gone away. They were simply being served as CSS the page needed in order to paint. I had removed the connection to another host, but the amount of CSS to read and interpret was unchanged. Compression lightened the transfer, yet the browser still parsed 375KB.
The lesson was short. Before changing where you serve from, change what you serve.
Let devices render Japanese, and self-host only the Latin fonts
The approach I settled on was to stop serving a Japanese web font altogether. The body font stack now walks through Japanese fonts already installed on the device.
"Noto Sans JP" at the front is used only if the device already has it; nothing is fetched. Macs fall to Hiragino, and Windows falls to Yu Gothic or Meiryo.
DM Sans and DM Mono ship as bundled Latin subsets. This is the current layout definition:
adjustFontFallback narrows the layout shift (CLS) when the web font arrives and replaces the fallback. I also removed every preconnect, preload, script-injected stylesheet, and noscript fallback that pointed at Google Fonts.
Here are the mobile PSI results after re-running:
Page
FCP
LCP
Score
CLS
Article page
4.1 s → 1.7 s
—
82
0
Top page
7.7 s → 1.7 s
9.5 s → 2.7 s
85
0
The top page's FCP shrank to about 22% of its previous value (1.7 ÷ 7.7), and the article page to about 41% (1.7 ÷ 4.1). Both come from the same mobile PSI profile, so they are not a promise about how a real phone feels.
Putting "it blocks rendering, so it is slow" through a minimal repro
Here is where the story stops being tidy.
My notes from the time gave this reason for the slowness: "Blink treats a <link rel="stylesheet"> inserted by a script in the head as render-blocking." Lighthouse does not flag it, so I took it to be an easy trap to miss.
The numbers did improve. Whether the explanation is right is a separate question. So I built a minimal repro: a tiny server that delays the CSS by 800 milliseconds.
// server.js — start with node server.js (localhost:8765)const http = require('http');const pad = '/*' + 'x'.repeat(120000) + '*/\nbody{font-family:sans-serif}'; // roughly 120KB of CSSconst pages = { // a script inserts <link rel=stylesheet> in the head '/inserted': `<!doctype html><html><head><meta charset=utf-8><script> var l=document.createElement('link');l.rel='stylesheet';l.href='/slow.css';document.head.appendChild(l); </script></head><body><h1>Hello</h1><p>Body text</p></body></html>`, // a <link> written directly in the HTML '/plain': `<!doctype html><html><head><meta charset=utf-8> <link rel=stylesheet href=/slow.css></head><body><h1>Hello</h1><p>Body text</p></body></html>`, // the common workaround: load as media=print, switch to all on load '/swap': `<!doctype html><html><head><meta charset=utf-8> <link rel=stylesheet href=/slow.css media=print onload="this.media='all'"></head> <body><h1>Hello</h1><p>Body text</p></body></html>`, // no external CSS (device fonts only) '/none': `<!doctype html><html><head><meta charset=utf-8> <style>body{font-family:"Hiragino Sans","Yu Gothic",Meiryo,system-ui,sans-serif}</style></head> <body><h1>Hello</h1><p>Body text</p></body></html>`,};http.createServer((q, r) => { if (q.url === '/slow.css') { // answer 800ms late setTimeout(() => { r.writeHead(200, {'content-type':'text/css'}); r.end(pad); }, 800); return; } const p = pages[q.url]; if (p) { r.writeHead(200, {'content-type':'text/html; charset=utf-8'}); r.end(p); return; } r.writeHead(404); r.end();}).listen(8765, () => console.log('up'));
The measuring side uses Playwright. It reads FCP and each resource's renderBlockingStatus, which is the browser's own report of whether a resource blocked rendering.
// probe.js — run with node probe.js (make sure NODE_PATH can resolve playwright)const { chromium } = require('playwright');(async () => { const b = await chromium.launch(); for (const path of ['inserted', 'plain', 'swap', 'none']) { for (let i = 0; i < 3; i++) { // never judge from a single run const ctx = await b.newContext(); // a fresh cache every time const pg = await ctx.newPage(); await pg.goto('http://localhost:8765/' + path, { waitUntil: 'load' }); const r = await pg.evaluate(() => new Promise(res => { new PerformanceObserver(l => { const e = l.getEntriesByName('first-contentful-paint')[0]; if (e) res({ fcp: Math.round(e.startTime), rb: performance.getEntriesByType('resource').map(x => x.renderBlockingStatus).join(',') }); }).observe({ type: 'paint', buffered: true }); setTimeout(() => res({ fcp: -1 }), 4000); // -1 if nothing paints within 4 seconds })); console.log(path, i, JSON.stringify(r)); await ctx.close(); } } await b.close();})();
On my machine, with the Chromium 153 build bundled with Playwright in headless mode, all three runs per page agreed:
Page
FCP (3 runs)
renderBlockingStatus
link written in the HTML
840–848 ms
blocking
link inserted by a script
36–48 ms
non-blocking
media=print swap
36–40 ms
non-blocking
no external CSS
64–72 ms
(not applicable)
The link written in the HTML waited the full 800 ms. The link inserted by a script painted before its CSS even arrived. In this minimal setup, the claim that a script-inserted stylesheet blocks rendering did not reproduce.
What I can confirm, and what I cannot yet say
Laid out plainly:
Claim
Status
Dropping the Japanese web font shrank mobile FCP on PSI
Confirmed on the real pages
Self-hosting through next/font/google made it worse (FCP 6.8 s)
Confirmed on the real pages
The CSS is heavy: about 121KB gzipped, 496 @font-face rules
Re-fetched and confirmed on the date of this article
A link written directly in HTML blocks rendering
Confirmed in the repro
A script-inserted link also blocks rendering
Not confirmed in the repro (unverified)
That last row is the "half" I mentioned at the start. On the real pages, something else may have been doing the damage. The timing of the insertion, the order against other stylesheets, and the interaction with hydration are all candidates, and I have not isolated any of them.
What I took from this as an indie developer: a change that worked and the explanation for why it worked are separate claims. I keep the improvement as a fact and write the reason only as far as I understand it. If I had carried around an explanation that failed its repro as "the cause", my next decision would have been wrong.
A sequel: fetching fonts at build time was an external dependency too
The story did not stop there. The Latin fonts were served through next/font/google, and on October 21 the build on Cloudflare Workers Builds started to fail.
The error was next/font/google queries have exactly one entry. A local next build passed, and only the build environment failed. next/font/google fetches from Google Fonts at build time, so any jitter in the response becomes a build failure.
The fix was to bundle the Latin woff2 files in the repository and switch to next/font/local, as shown above. The real benefit of self-hosting was not a closer server. It was that the build no longer depends on someone else's response.
# Any remaining reference to Google Fonts at runtime or at build time?grep -rnE "fonts\.googleapis|fonts\.gstatic|next/font/google" src --include=*.tsx --include=*.ts --include=*.css
When I ran it on the day of writing, it found one @import url('https://fonts.googleapis.com/...') left in the file for the 404 page. Regular pages never load it, so it does not affect the numbers above. Still, it showed how a spot I believed was fixed can come undone with a single grep.
Decide the cost and the boundary before adopting this
Leaning on device fonts has a price.
The look changes per environment. Macs use Hiragino, Windows uses Yu Gothic or Meiryo, and stroke weight and letterforms will not match. It is a poor fit for a site whose design depends on one typeface.
Thin weights may not appear. If the device has no 300, it falls back to 400.
The height difference against Latin text needs adjustFontFallback and size-adjust to close.
My own judgment: for reading-oriented pages I put readability and speed first and let devices render the text. Where the typeface is part of the work, such as share images or decorative headings, I handle it separately with a subset limited to the characters in use. I would not promise the same call for a page where typography is the main subject.
In order:
Measure the external font CSS: gzip transfer, expanded size, and the number of @font-face rules
If you try self-hosting, put CSS size and FCP side by side and check it did not get worse
Verify explanations like "it blocks rendering" on a repro page first (the two scripts above are a starting template)
After the fix, grep for leftover Google Fonts references and re-run PSI
Next time a PSI number looks bad, run the curl at the top before you open the image folder. The size of that CSS changes where you look first.
Share
Thank You for Reading
Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.