← All news

Engineering 11 min read

How we took our Chinese and Japanese pages from 77 to 100 on mobile Lighthouse

Our /ja page scored 77 on mobile Lighthouse with a CLS of 0.179, and /zh took 3.84 s to paint its headline. The biggest culprit was not the CJK fonts but a locale store that answered "en" before the page data arrived. What we changed, what we measured, and the obvious fix we measured and threw away.

Chinese and Japanese pages on mobile Lighthouse: /ja from 77 to 100, LCP 3.84 s to 1.66 s, CLS 0.179 to 0

Our marketing site is a SvelteKit app that serves the same pages in English, Traditional Chinese and Japanese. The English home page did fine on mobile Lighthouse. The Chinese and Japanese pages did not: /zh scored 86 with an LCP of 3.84 s, and /ja scored 77 with a CLS of 0.179 on top.

The obvious suspect is fonts. CJK web fonts are big, Noto Sans TC alone is several megabytes spread over many subsets, and that is where we started looking too. But the main reason /ja jumped around and /zh painted its headline so late turned out to be a bug that had little to do with fonts. This post follows the investigation in order. Every number was measured, and the code is what runs on the site today, trimmed.

The results first

PageMobile scoreMobile LCP (s)Mobile CLS
/ (English)92 → 1003.01 → 1.660.001 → 0.001
/zh86 → 1003.84 → 1.660 → 0
/ja77 → 1003.91 → 1.660.179 → 0
/zh/pricing93 → 1003.01 → 1.650.003 → 0.003
Template page¹91 → 1003.16 → 1.650 → 0
Before → after. Lighthouse 12.8, simulated mobile, local production build, median of three runs. ¹ /zh/templates/quote/air-conditioning
Fig. 1 · Lighthouse mobile
Lighthouse 12.8, simulated mobile, local production build, median of three runs per page. Before = main right before the change, after = the merged change. Production spot check after release: /zh and /ja 97, LCP 1.3 s, CLS 0.

How these were measured: Lighthouse 12.8 with its default simulated mobile throttling, against a local production build, median of three runs per page, both versions on the same machine in the same session. After the release we spot-checked production: /zh and /ja both scored 97, with an LCP of 1.3 s and a CLS of 0 (one run each). We had also run Lighthouse against production before the change, but single runs were all over the place: the same /zh scored 63 on one run and 89 a few minutes later. So the before/after comparison uses the local medians.

The symptom: LCP was almost all render delay

Lighthouse's LCP breakdown made the shape of the problem clear. On /zh the LCP element is the headline text, not an image, so there is no resource load phase at all. Of the 3.84 s, 0.45 s was time to first byte and the other 3.39 s (88%) was render delay. The HTML had arrived; the text just was not being painted.

Fig. 2 · What the /zh headline waited for
Lighthouse LCP breakdown for /zh on simulated mobile (one run of each three-run set, both at their median LCP). The LCP element is the headline text, so there is no image load phase: everything after the first byte is render delay. Chips: request priority as recorded in the same reports.

What was the browser doing instead? The request list in the same report:

  • the Google Fonts stylesheet for Noto Serif TC and Noto Sans TC, 135 KiB transferred (about 490 KiB uncompressed), at VeryHigh priority;
  • the Google Fonts stylesheet for the Latin fonts (Newsreader, Inter, JetBrains Mono), also VeryHigh;
  • about 50 JS modulepreloads;
  • and on a fast connection, where the JS was already there, the browser ran the whole hydration before painting the server-rendered page.

We took these apart one by one. But first, the surprising one.

Trap 1: the locale store said "en" before the page data arrived

It started with a small thing we noticed: while /ja loaded, the flag in the language switcher went Japan → US → Japan.

The site's locale is a store derived from SvelteKit's $page. The server load decides the language and puts it in the page data. The problem was the window at the start of hydration, before the first page data was there: the store had no value, so it answered its default, 'en'. And a subscriber to that store keeps <html lang> in sync.

// before
export const locale = derived(page, ($page): Locale => {
	const raw = ($page?.data as { locale?: unknown } | undefined)?.locale;
	return raw === 'zh' ? 'zh' : raw === 'ja' ? 'ja' : 'en';
});

So for a few hundred milliseconds, <html lang> on /zh and /ja said en. Our CJK fonts are switched through CSS variables keyed on selectors like html[lang="ja"], and the headline size depends on the language too. During that window the headline was laid out again in the English font and size, then switched back and laid out a third time when the page data arrived:

  • on /ja, the two reflows were a 0.179 layout shift (0.094 on desktop);
  • on /zh, the largest text block was repainted when it switched back, pushing LCP later;
  • language-dependent blocks hydrated in English, then flipped back to Chinese or Japanese.
Fig. 3 · The locale flash on /ja
Schematic, not to scale; "a few hundred ms" is the window between the scripts running and the first page data. CLS figures: Lighthouse simulated mobile, /ja, median of three.

The fix is small: with no page data yet, read the language from the URL. The server already decides the language from the URL prefix, so using the same rule keeps both sides in agreement.

// after (src/lib/i18n/locale.ts)
export const locale = derived(page, ($page): Locale => {
	const raw = ($page?.data as { locale?: unknown } | undefined)?.locale;
	if (raw === 'zh' || raw === 'ja' || raw === 'en') return raw;
	// No page data yet: the URL already says which language the page is.
	if (browser) return localeFromPath(location.pathname);
	return 'en';
});

That alone took the /ja CLS to zero. The lesson: on a multilingual SSR site, ask of every default value whether it can differ from what the server rendered during early hydration. <html lang> is not only for accessibility and SEO; through CSS it can swap your fonts.

Trap 2: a 135 KiB font stylesheet at the highest priority

We load the CJK fonts from Google Fonts with display=optional, already split per language: /zh loads only TC, /ja only JP, English pages none. The issue was how. To avoid blocking render, the stylesheet was a rel="preload" as="style" that a script turned into a stylesheet once it loaded:

// before (src/lib/seo/fonts.ts)
return `<link id="nx-fonts-cjk" rel="preload" as="style" href="${href}" /><noscript><link rel="stylesheet" href="${href}" /></noscript>`;

preload does not block rendering, but it is fetched at the highest priority. A Google Fonts CJK stylesheet lists hundreds of unicode-range subsets, so it is 135 KiB over the wire on its own. It and the roughly 350 KB of font files it went on to start were fetched at top priority before the first paint, sharing a phone's connection with the CSS the page actually needed to paint.

The change: no font stylesheet goes in before the first paint. The Latin fonts are now self-hosted (the exact Google Fonts files and @font-face rules, display=swap), so English pages need no third-party connection for fonts. CJK stays on Google Fonts, split per language, with display=optional: if the font is not ready almost at once, this page view keeps the system CJK font and never swaps late, so there is no late LCP; the font is cached for the next page. Our CSS variables put system CJK fonts ahead of Noto, so devices that have their own CJK fonts never download them at all.

Trap 3: when to add the font stylesheets, and a measured trade-off

The server puts both stylesheets into an inert <template>, which the browser does not fetch, plus a <noscript> copy for visitors without JavaScript:

// src/lib/seo/fonts.ts
const links = (media: string) => sheets.map((href) => `<link rel="stylesheet"${media} href="${href}" />`).join('');
return `${preconnect}<template id="nx-font-sheets">${links(' media="print"')}</template><noscript>${links('')}</noscript>`;

A small inline script in <head> waits for first-contentful-paint and then clones the links out of the template. media="print" makes the browser fetch them at the lowest priority; they switch to all once loaded. Our CSP does not allow inline onload="…" attributes, so the listeners are attached in JS.

Review of the first version caught a regression: first visits were fine, but on repeat visits, with the fonts already cached, every page still painted first in the fallback fonts (Georgia and system sans) and then swapped to Newsreader and Inter. A flash on every page.

The fix is a localStorage flag. Once the Latin stylesheet has loaded, the script records nx-font-sheets-seen. On the next visit it adds that stylesheet immediately with blocking="render", so the first paint already uses the cached fonts:

// src/app.html (excerpt)
function start(early) {
	// …clone each <link> out of the <template>…
	if (early && own) {
		el.media = 'all';
		el.setAttribute('blocking', 'render');
	} else {
		el.addEventListener('load', function () { el.media = 'all'; }, { once: true });
	}
	// …and set nx-font-sheets-seen once the self-hosted sheet has loaded
}
if (seen) return start(true);
new PerformanceObserver(function (list, obs) {
	if (list.getEntriesByName('first-contentful-paint').length) {
		obs.disconnect();
		setTimeout(start, 0);
	}
}).observe({ type: 'paint', buffered: true });
setTimeout(start, 3000); // a background tab never paints

For this part we used throttled runs in real Chromium rather than Lighthouse, first visit (cold) and repeat visit (warm):

BuildCold FCP
/ · /zh
Cold: fonts
at FCP
Warm FCP
/ · /zh
Warm: fonts
at FCP
Before708 / 700 ms0/3356 / 280 ms3/3
First version (deferred only)660 / 628 ms0/3308 / 340 ms0/3 (flash)
Final (repeat-visit flag)632 / 608 ms0/3312 / 328 ms3/3
Inlined @font-face (tried, not kept)704 / 612 ms0/3356 / 340 ms3/3
Real Chromium, not simulated: 150 ms RTT, 1.6 Mbps, 4x CPU slowdown, 412 px viewport, three runs. Warm = second load of the same page with the cache intact. "Fonts at FCP" = Newsreader and Inter were loaded when first contentful paint was reported.

The last row is the obvious alternative: inline the @font-face rules in every page. Repeat visits are fine that way too, but on a first visit the browser then fetches the font files before the first paint, and mobile Lighthouse dropped from 100 to 78 on / (LCP 1.85 s → 4.35 s) and from 99 to 87 on /zh. So we kept the flag.

Trap 4: 50 modulepreloads, and hydration before the first paint

By default SvelteKit sends a modulepreload for each JS module the page uses. On our home page that was about 50, all requested at high priority before the first paint, sharing the connection with the CSS the paint was waiting for. The pages are server-rendered and display without JS, so we keep preloading CSS only:

// src/hooks.server.ts
const response = await resolve(event, {
	preload: ({ type }) => type !== 'js'
	// …
});

The entry scripts at the end of <body> still load at once, and each route's modules are fetched together when hydration starts.

The other half is when hydration runs. When the JS is already at hand (a repeat visit, a fast connection), the browser ran the whole hydration before painting the server-rendered page. SvelteKit's hooks.client.ts can export an init function, and hydration does not start until it resolves, so we make it wait for the first paint:

// src/hooks.client.ts
export const init: ClientInit = () =>
	new Promise<void>((resolve) => {
		const go = () => setTimeout(resolve, 0);
		try {
			if (!PerformanceObserver.supportedEntryTypes.includes('paint')) throw new Error('no paint timing');
			new PerformanceObserver((list, observer) => {
				if (list.getEntriesByName('first-contentful-paint').length) {
					observer.disconnect();
					go();
				}
			}).observe({ type: 'paint', buffered: true });
		} catch {
			requestAnimationFrame(go);
		}
		setTimeout(resolve, 1500);
	});

The setTimeout(resolve, 1500) is for background tabs: they never paint, and they should not wait forever to hydrate.

The risk with later hydration is slower interaction. With the same throttling we measured when each page finished hydrating: / at 2.77 s (2.75 s before) and /zh at 3.16 s (4.25 s before). The English home page is the same within noise, and the Chinese page is hydrated more than a second sooner, because its resources no longer fight each other.

One side effect: the load event can now fire before hydration, so end-to-end tests cannot click a JS control right after goto. The root layout sets <html data-hydrated> on mount, and tests that click wait for it.

Two small changes that paid off

Logos as WebP at display size. The home page's integration marquee and task icons used the original logo files (up to 3301 px wide, 8–57 KB each) shown at most 44 px tall. As WebP at most 96 px tall and 300 px wide (2–3x the shown size), the 16 files went from about 438 KB to about 37 KB. width and height match the new files, so the reserved shape is unchanged.

No article bodies in the home bundle. A home page section imported formatDate from $lib/content, which pulled the marked Markdown parser (36 KB) into the home page. And the ?raw glob that lists news titles on the home page shipped every article body in all three languages. formatDate moved to its own file, and a small Vite plugin answers ?frontmatter imports with the leading --- block only:

// vite.config.ts
function frontMatterOnly(): Plugin {
	return {
		name: 'nx-front-matter-only',
		enforce: 'pre',
		load(id) {
			const [file, query] = id.split('?');
			if (query !== 'frontmatter' || !file.endsWith('.md')) return;
			this.addWatchFile(file);
			const block = readFileSync(file, 'utf8').match(/^---\n[\s\S]*?\n---/);
			return `export default ${JSON.stringify(block ? block[0] : '')};`;
		}
	};
}

Home route JS went from 470 KB to 409 KB uncompressed.

What we did not do, and the limits of these numbers

  • blocking="render" is Chromium-only. Safari and Firefox still add the stylesheet at once on repeat visits, but without blocking render, so a very brief font swap is still possible there.
  • The CJK fonts are still on Google Fonts. Lighthouse still lists that stylesheet as unused and unminified CSS. It now loads after the first paint on a first visit, but we did not self-host a CJK subset.
  • If the cache is evicted but the flag survives, the first paint waits for one same-origin request (about 2 KB).
  • No fallback-font metric overrides (size-adjust and friends). The /ja CLS came from the locale flash; the font swap itself measures about 0.0001.
  • The number of JS modules did not go down. We tried Rollup's experimentalMinChunkSize, and it dragged a lazily loaded package into the eager graph, so we removed the preloads instead. About 47 small modules still load during hydration, just after the first paint.
  • Lab numbers are not real users. The table is simulated mobile against a local production build (HTTP/1.1, localhost); the production 97 and 1.3 s are single spot checks. Field Core Web Vitals need weeks of real traffic before they mean much.

If you run a Chinese or Japanese site

  1. Read the LCP breakdown first. If the LCP element is text and render delay is most of it, the problem is not slow downloads; something is blocking or competing with the paint.
  2. Check every value that depends on page data during early hydration, especially a locale that ends up in <html lang>. The quick test: load a non-English page with slow throttling and watch the language switcher or <html lang> for a flicker.
  3. rel="preload" is not "load quietly", it is "load first". If the first screen does not need it, do not preload it.
  4. A Google Fonts CJK stylesheet is over 100 KB by itself. Split it per language, use display=optional, and add it after the first paint.
  5. Measure every fix that feels obviously better. Inlining @font-face looked simpler; measured, it took first-visit LCP from 1.85 s to 4.35 s.

NEXUPIRA builds AI coworkers for small businesses: when a customer asks for a price on LINE, it drafts the reply and the quote, and it keeps the books and the calendar. This post was about its website; take a look if you are curious.