Japanese Web Fonts and Core Web VitalsFixing LCP and CLS by Measurement
When I audited this site's (notes.rewheel.dev) SEO, the technical items were almost a perfect score. The sitemap, robots.txt, canonical, OGP, and JSON-LD were all in place, and the Lighthouse SEO score was 100 on every page. Yet only Performance was stuck at 58-63.
As I dug in, what was dragging it down were two of the Core Web Vitals — LCP (loading speed) and CLS (layout shift). And the culprits were in different places than I first suspected. LCP was how the Japanese Web font was loaded; CLS was not the body images but the header logo. This article is a record of pinpointing those culprits by measurement and fixing them without adding measures that do not work.
This assumes you understand the basics of Web frontend (HTML/CSS, browser loading). Docusaurus-specific topics come up too, but the ideas of render-blocking and CLS apply to static sites in general.
Why fix Core Web Vitals
Core Web Vitals are metrics of user experience defined by Google, and they are also a search-ranking signal. There are three metrics, and the "good" thresholds as of 2026 are as follows (from web.dev).
| Metric | What it measures | good | poor |
|---|---|---|---|
| LCP (Largest Contentful Paint) | Time until the main content is painted | ≤ 2.5s | > 4.0s |
| CLS (Cumulative Layout Shift) | Cumulative layout shift during loading | < 0.1 | > 0.25 |
| INP (Interaction to Next Paint) | Responsiveness to interaction | < 200ms | > 500ms |
You might think "if technical SEO is a perfect score, isn't that enough?", but the SEO score is only an audit of machine readability. Even if the title and structured data are correct, if the page is slow or lurches during loading, users bounce and rankings do not rise. CWV quantify the felt side of that.
It is a static site built with Docusaurus v3.10, using the Japanese font Klee One for the body text. Japanese Web fonts have many glyphs and large files, so CWV exhibit different quirks than English sites. That was the crux this time.
Baselinewhat was "failing"
First I measured production (notes.rewheel.dev) with Lighthouse's mobile setting (4x CPU throttling).
| Page | Performance | LCP | CLS | SEO |
|---|---|---|---|---|
| Top (/) | 58 | 9.3s | 0 | 100 |
| Blog post | 62 | 4.4s | 0.287 | 100 |
| Docs | 63 | 4.1s | 0.305 | 100 |
LCP was poor (over 4 seconds) on every page, and CLS was poor (over 0.25) on the blog and docs. SEO was a perfect score. The location of the problem became clear. Performance — specifically LCP and CLS.
From here I chased the two culprits separately.
Culprit 1the render-blocking font that was slowing LCP
What Lighthouse's render-blocking-insight pointed to was the CSS loaded synchronously in <head>. The heaviest among them was the Google Fonts CSS.
The specification at the time loaded these four families together in a single URL.
https://fonts.googleapis.com/css2?family=Klee+One&family=Noto+Sans+JP&family=Inter&family=JetBrains+Mono
The problem is the Japanese fonts (Klee One / Noto Sans JP). Japanese exceeds 2,000 characters in common-use kanji alone, and the font is split into hundreds of subset files for delivery. The CSS body alone is about 150KB, and moreover it is render-blocking — meaning the browser cannot paint the body until it finishes fetching this CSS. On mobile, where CPU and network are throttled, several seconds melted away here.
The browser's loading drawn as a diagram looks like this. With synchronous loading, the font CSS cuts in front of First Paint.
There were two more wastes.
- Inter was loaded but not used. Inter appears nowhere in
font-family; it was a family that purely increased transfer size. - The KaTeX CSS was also loaded synchronously on every page. Math appears in only some posts, yet it was render-blocking even on the top page.
Culprit 2what caused CLS was not the images but the logo
For CLS, I first suspected the font. A Web font switches from the fallback when loading completes (swap), so the character width changes, lines reflow, and the layout shifts — that is a typical CLS factor.
But the numbers did not add up. CLS was high on the blog (0.287) and docs (0.305), nearly the same value. If font swap were the main culprit, a difference should appear in proportion to the body text amount, but it did not. And the decisive thing was that even the docs page, which has no hero image, had CLS 0.305. If body images were the culprit, pages without images should drop. They did not.
So the culprit is an element common to every page. That was the navbar coming right after <head>, and the logo inside it.
When the navbar's logo <img> has no width/height, the browser cannot reserve the area the logo occupies before loading. It paints the body crammed up at height 0, and the instant the logo image arrives, it reserves the area and pushes the body down. This accumulated into CLS.
CLS investigation tends to suspect "flashy body elements" first, but it is often faster to first check whether the header/footer common to every page has an image with no dimensions specified. Common elements raise the CLS floor across all pages.
As for font swap, at this point I put it on hold as "maybe contributing secondarily." I settle it later by measurement (foreshadowing).
Implementing the fixes
For the identified causes, I made five changes. The first three are the main event, directly affecting CWV.
1. Remove the unused Inter
I removed Inter, which does not exist in font-family, from the Google Fonts URL. The appearance does not change at all; only the transfer size drops. A zero-risk net gain.
2. Add width/height to the navbar logo
The direct fix for the CLS main culprit. Docusaurus's navbar.logo accepts width/height.
navbar: {
logo: {
alt: 'Reinvent Notes Logo',
src: 'img/logo.svg',
// CLS fix: reserve the area before loading (the measured main cause of CLS)
width: 32,
height: 32,
},
// ...
}
Now the browser can reserve the logo area before loading, and the push-down on image arrival no longer happens.
3. Make the font and KaTeX CSS asynchronous
The fix for the LCP main culprit. To resolve render-blocking, I moved to the standard pattern of fetching with preload and swapping to rel="stylesheet" when loading completes. In Docusaurus, you can inject tags into <head> with headTags.
headTags: [
{
tagName: 'link',
attributes: {
rel: 'preload',
as: 'style',
href: 'https://fonts.googleapis.com/css2?family=Klee+One:wght@400;600&family=Noto+Sans+JP:wght@400;500;700&family=JetBrains+Mono:wght@400;500&display=swap',
// promote to a stylesheet when loading completes, avoiding render-blocking
onload: "this.onload=null;this.rel='stylesheet'",
},
},
// Make the KaTeX CSS asynchronous the same way (resolving render-blocking on pages without math)
// Also write a synchronous fallback in <noscript> for JS-disabled environments
],
The loading timeline changes from synchronous to asynchronous like this.
After the fix, it first paints with the fallback system-ui and swaps once the font arrives. Because I add display=swap, the text stays visible even while the font is being fetched (avoiding FOIT; for details see MDN: font-display).
Along with this, I also moved the font that I had been loading via @import in custom.css over to the headTags side, resolving the serial loading caused by @import inside CSS.
4. Convert images to WebP
I replaced the top hero image and the OGP social-card from PNG to WebP and removed the old PNGs. The hero is about 90% lighter. This lowers the transfer size of large images that could be LCP candidates.
5. Generate llms.txt (a bonus)
On a different axis from CWV, I also added a plugin that generates /llms.txt at build time for AI crawlers. It is a measure conscious of inflow via generative AI (GEO/AEO).
Verificationyou can't measure CLS without throttling
The effect of the fixes is invisible if you measure it the wrong way. At first I measured a local build with no limits, and CLS was 0.01. That tells me nothing about where the original 0.3 went.
The reveal is that CLS depends strongly on network/CPU slowness. When the connection is fast, both the font and the logo arrive instantly, and painting settles before any shift occurs. Unless you reproduce production-equivalent mobile slowness, the problem does not reproduce in the first place.
So I measured with a production-equivalent throttle using the performance trace of Chrome DevTools (via MCP).
CPU: 4x throttling
Network: Slow 4G
Viewport: 360 x 640 @2x (mobile, touch)
Under this condition, here is the result of re-measuring the docs page after adding dimensions to the logo.
| Page | CLS before | CLS after |
|---|---|---|
| Docs (category) | 0.305 | 0.00 |
| Docs (body 250KB) | — | 0.00 |
Even pages with a lot of body text were 0.00. DevTools' CLSCulprits insight also reported "no relevant layout shift," and the shift itself disappeared.
LCP also improved greatly locally, but because localhost has no round-trip latency to a CDN, I cannot compare this number directly with production LCP. The production after-fix LCP needs to be measured separately (I do not assert it in this article). What I can say for sure is only the structural change of "removing the render-blocking font/KaTeX CSS from the critical path."
Why I did not add size-adjust
Here I return to the font-swap topic I had put on hold.
A frequently cited CLS measure for Japanese fonts is making the fallback font's metrics match the Web font with size-adjust. It prevents the reflow shift by keeping character width and line height from changing at the moment of swap. When you hear "Japanese font + CLS," adding this even looks like the standard move.
But this time I did not add it. The reason is that measurement showed font swap contributes almost nothing to CLS. Just by adding dimensions to the logo, CLS was 0.00 even on pages with a lot of body text. If swap were causing reflow, a value should remain here, but it does not.
It also makes structural sense. The body's line height is decided in custom.css like this.
:root {
--ifm-font-size-base: 16px;
--ifm-line-height-base: 1.8; /* unitless */
}
line-height is unitless (1.8) and font-size is fixed (16px). Then one line's height is always 16px x 1.8 = 28.8px, so the line-box height does not change no matter which font you swap to. It was a structure where vertical reflow is unlikely to occur.
size-adjust is a correct technique, but it only takes effect when "font swap is actually causing CLS." This time that premise did not hold. Adding a measure that might not work "just in case" leaves unverified code behind, which becomes a maintenance liability. I measure the target metric (CLS), and if it is already met, I do not add extra defense — that is what I decided.
To be honest about one caveat, this time's after-fix measurement observes a swap with the font already cached (warm cache). On a completely first visit (cold), there remains theoretical room for a tiny CLS as lines re-wrap due to the character-width difference. But when I previously measured under fast conditions close to cold, CLS was 0.01 — 1/10 of the good threshold 0.1. I judge it is not at a level that causes real harm. If you are concerned, you can dig further with a measurement that isolates the cold cache.
Wrap-up
What I learned from fixing this site's Core Web Vitals comes down to three points.
- The LCP main culprit was the render-blocking of the Japanese Web font. I made the 150KB synchronous CSS asynchronous with
preload -> swapand removed the unused Inter, clearing the critical path. - The CLS main culprit was not the font but the navbar logo with no dimensions. The clincher was that CLS was high even on pages without images, and just adding
width/heightmade the shift disappear. - I did not add size-adjust, as a result of measurement. Thanks to the unitless
line-height, reflow from swap is small and CLS is already 0. Not adding a measure that does not work is also part of optimization.
Rather than short-circuiting to "Japanese fonts are heavy -> just do font measures," what worked was measuring LCP and CLS separately and pinpointing different culprits for each. If I do more next time, it will be re-measuring the production after-fix LCP and digging into CLS with an isolated cold cache.
- Web Vitals (web.dev):definitions and thresholds of each metric
- Largest Contentful Paint (web.dev):the breakdown and improvement of LCP
- font-display (MDN):swap and FOIT/FOUT
- Docusaurus: head metadata:injecting into
<head>withheadTags