DevInsight

A developer's field notes

Frontend
6 viewsAbout 7 min read

The Page Is Already Shifting Before the Font Even Shows

Korean web fonts carry large per-glyph payloads that delay loading, and the later a font arrives, the worse the CLS metric swings. This article walks through subsetting glyphs, nailing down preload timing, and choosing a font-display strategy with real numbers. It separates what Next.js's next/font handles from what it doesn't, and shows how to design a font pipeline that keeps text rendering stable without blowing the performance budget.

Published by DevInsight.

#웹 폰트#CLS#Core Web Vitals#Next.js#next/font#font-display#preload#subset#웹 성능

A woff2 file for a full Korean web font set usually exceeds 1–2MB. Compared with a Latin font at the 20–50KB range, that's a difference of two orders of magnitude. The cause is simple. Korean precomposes its syllables from 19 initial consonants, 21 medial vowels, and 28 final consonants into prebuilt syllables, and the count reaches 11,172. Unlike Latin, where you roll a few dozen characters in and call it done, the scale is different from the start. Even compressing from ttf to woff2 doesn't change the glyph count, so the amount of payload to handle is large to begin with.

How this shows up on screen is the real problem. The payload is big, so downloads take longer, and the later a font arrives, the longer the browser renders text with a fallback font. Then the moment the real font arrives, it pushes out the fallback and the page shifts once. That shift lands directly in Core Web Vitals, the core metric for web performance, and in CLS in particular. This is what the title is hinting at. Before the font is even visible, rendering is already running on the fallback basis, and when the real font arrives, that very basis collapses.

There are two paths by which fonts inflate CLS. font-display: block hides text until the font arrives, so the first impression stays fixed, but if loading is slow, blank text stretches that much longer and layout gets recalculated at the swap moment. swap shows the fallback from the start and swaps in the font when it arrives. Either way, if the fallback and the real font have different glyph metrics, a shift occurs. CLS adds up shifted pixel area converted against the viewport. Even if a single font changes line height by 2px, with dozens of lines of body text the shifted area adds up.

Subsetting only works halfway for Korean

Subsetting is the task of stripping out glyphs the font doesn't use. For Latin fonts, splitting by Unicode range works well. A–Z, symbols, and accents each fall into their own ranges, so splitting by unicode-range lets the browser download only the slices it needs. Google Fonts doing the same split into latin, latin-ext, and korean follows the same principle.

For Korean, this approach only half works. Precomposed syllables sit contiguously in U+AC00–U+D7A3, so whether you divide the range into four or ten parts, the glyphs actually used are scattered evenly across each slice. Serving all 11,172 syllables means the gain from splitting is far smaller than what you get with Latin. That's why in practice the industry leans toward dynamic subsetting, which extracts only the syllables a page actually uses. A typical Korean article or two will use around a thousand or so syllables, and trimming that down brings a 1.5MB font to roughly 100–300KB. Back in the era of downloading a full precomposed font, it wasn't rare for a single font to exceed 5MB.

Dynamic subsetting has a cost. Where you extract the "used glyphs" is precisely the failure condition. If you extract only from the article body text, every time new content comes up, missing syllables appear and tofu boxes render in their place. Whether you source the extraction from the entire content set, regenerate periodically, or supplement the edges—emoji, special characters, and double-consonant final codas—with a separate list, you need to be careful. Running a smoke test in CI that checks whether tofu actually appears in the deployed environment puts your mind at ease.

Preload the font for LCP text only

Preload isn't something you attach to every font. It's right to make the preload target only the font used by the text drawn first on the initial screen—that is, the LCP element. If it's a heading or an above-the-fold paragraph, it's an LCP candidate, so preload it; a body font that's only visible after scrolling is better left unpreloaded. Preload, by nature of fetching in advance, steals bandwidth from other resources.

In plain HTML, the most common pitfall when handling preload is a missing crossorigin attribute. Font requests happen in CORS mode by default. A preload link attached without crossorigin isn't recognized as a font, so in the end the preload and a separate request both go out redundantly. It has to take the form <link rel="preload" as="font" type="font/woff2" crossorigin>.

By this point, a policy of splitting fonts into two tiers naturally follows. Fonts for the initial screen get preloaded, and fonts for content further down are loaded via JavaScript after document loading finishes, or only triggered when they enter the viewport via IntersectionObserver. This lazy-loading strategy combined with font-display is the skeleton of the font pipeline design.

font-display is about choosing a trade-off

font-display's four values each charge a different cost.

  • block: hides text until the font arrives. You pay up to 3 seconds of blank text time, and it applies immediately once loading finishes.
  • swap: draws immediately with the fallback and swaps in the font when it arrives. Text is visible right away, but the shift potential is the greatest.
  • fallback: hides for only 100ms, and allows the swap only for a short window afterward. If the server is fast, it blocks the swap for a font that arrives late.
  • optional: uses the font if it's ready within the initial 100ms, and otherwise gives up on it entirely for this page load. There's almost no shift, but on slow networks the font may not appear at all.

For a page that puts CLS first, optional is effectively the only choice. The price is accepting the fact that "slow first-time users won't see the font." For a page where the font itself is the brand, you use swap, but you should also add fallback-font metric correction.

This is where next/font's strengths and weaknesses divide. Next.js's next/font downloads fonts at build time, self-hosts them, attaches preload links, and sets font-display: swap as the default. Self-hosting removes the connection delay to the Google Fonts CDN, and hashed filenames eliminate cache-refresh problems. But swap is a fixed value, so there's no option to change it to optional. If CLS comes first and you want optional, you have to give up next/font and write @font-face yourself. It means next/font is a tool that makes fonts easy to use, not a tool that decides the entire font-loading strategy for you.

The same goes for fallback-font metric correction. Aligning the fallback to the real font's metrics with size-adjust, ascent-override, descent-override, and line-gap-override keeps shift near zero even when a swap happens. next/font auto-generates these correction values with Latin-centric math, but you shouldn't take it as a given that those values are accurate for Korean fonts. Given Korean's trait that line heights vary widely by font, you should pick the fallback yourself, plug in the correction values, and measure the text block height before and after the font load with getBoundingClientRect to confirm the shift converges to zero.

Optimization never finishes without a budget

A font pipeline only closes its design once you set a performance budget. Without first deciding numbers like roughly 100KB of initial-screen font transfer or within 300KB of total resources, "just a bit smaller..." goes on forever.

Verification starts with the build output. Sampling per-font transferSize and download timing with performance.getEntriesByType('resource') reveals whether preload is actually working and whether fonts arrive after LCP. Lighthouse's font-display audit is also a useful reference. In the field, CrUX's CLS percentiles are the standard. Just be aware that in dev environments or locally, fonts are already cached so shifts are almost invisible, so you have to clear the cache and check under a simulated slow network.

The timing when CLS blows up in the field is less about the font load itself than when it coincides with scrolling. If a font arrives while the user is scrolling and layout changes, the shift at that moment gets added up. In body text where scrolling is heavy, if the line-height difference between the fallback and the real font is large, CLS shoots up in this situation. This is why metric correction matters more in Korean than in Latin. A whole body of dozens of lines changing line height at once happens far more frequently than with Latin text.

Lastly, if you've introduced dynamic subsetting, content growth becomes font maintenance. Either put a check script in CI that catches missing syllables in new content, or periodically regenerate the font from the union of syllables in production content—you must keep at least one of the two in place. The moment font optimization enters the deployment pipeline, CLS becomes a manageable problem. Initial-screen fonts on optional, delayed fonts on scroll trigger, and metric correction verified by direct measurement—just getting these three in order, in sequence, noticeably reduces the page's shifting.

Comments

Loading comments.

Good Follow-up Reads

Posts connected to the topic you just read.

View all Frontend
Frontend

One 'use client' Line Splits Server and Client, and Shakes Everything From Bundle Size to State Management

In the Next.js App Router, a single 'use client' line that draws the server-client boundary reshapes your entire bundle size, data fetching, and state management strategy. Draw that boundary wrong and bundle bloat plus duplicate fetching blow up all at once, late in the game. This memo lays out the judgment principles for redrawing the boundary based on per-layer responsibilities and data ownership, plus the priority order for refactoring boundaries that were already drawn wrong.

#React#Next.js#Server Components#Client Components
Frontend

next/image sizes 한 줄이 LCP를 0.5초 당긴다

LCP 개선을 위해 무작정 이미지를 압축하고 CDN을 도입하기 전에, next/image의 sizes 속성과 priority 플래그가 실제로 어떤 영향을 미치는지 정량적으로 이해해야 한다. 이 글은 next/image 설정값이 LCP에 미치는 영향을 실제 코드 레벨에서 분석하고, 이미지 CDN이 진짜 필요한 상황과 불필요하게 최적화를 도입했다가 역효과를 보는 사례까지 함께 다룬다.

#LCP#이미지최적화#next-image#CoreWebVitals

Previous post

The Struggle of One Project's Upgrade from `any` Hell to `strict`

Next post

Reading the Signs in EXPLAIN: When Queries Ignore Your Indexes

DevInsight Digest

Keep every new article in one calm feed.

Follow the full publication feed without promotional alerts.

Subscribe to RSS