Shopify font loading: where the files come from, what font-display decides, and what a custom face costs on LCP and CLS
Shopify serves library fonts from the store's own domain and font_face adds no font-display unless asked. The files, the swap, the preload cap, a ten-minute check.
12 min read
Contents — 6 sections
- A web font is a render dependency, and the browser decides what to show while it waits
- The font picker decides the files, and the theme decides how they are declared
- Where the declaration sits in the layout decides when the request starts
- A face from outside the library costs a connection before it costs a byte
- Check it in ten minutes on the product page
- Fix it in this order, and the first pass is an afternoon
A web font is a render dependency, and the browser decides what to show while it waits
A @font-face rule on its own downloads nothing. Google's font guidance on web.dev is explicit: the file is requested only when styling that uses the family applies to text on the page, so the request cannot start until the browser has found the declaration, parsed the stylesheet that holds it, and met an element that needs it. Shopify's system fonts note lists the same four steps, discover, request, download, apply, and puts the wait at tens of milliseconds on a fast connection and several hundred on a slow one.
What the text does during that wait is the font-display descriptor's decision, and web.dev's table is the whole of it:
| Value | Text invisible for | Then the web font swaps in for |
|---|---|---|
auto | The browser's choice: up to 3 seconds in Chrome and Firefox, indefinitely in Safari | The browser's choice |
block | 2 to 3 seconds | As long as it takes |
swap | 0 ms | As long as it takes |
fallback | 100 ms | 3 seconds, then the fallback stays |
optional | 100 ms | Never; the fallback stays for the visit |
Two metrics hang on that choice. The first is largest contentful paint. Web.dev's LCP guide splits the metric into four parts, and when the largest thing on the first screen is a heading set in a web font, the font is the LCP resource: the time before its request is resource load delay, the download is load duration, and with auto or block the paint waits for the file. Any other value paints the heading in the fallback and the metric stops waiting on a second request. The second is cumulative layout shift. Shopify's font swapping note puts the swap 100 to 500 milliseconds after first paint, and if the fallback's glyph widths or line heights differ, every line reflows in one frame, which on a text-heavy page it scores at 0.05 to 0.15 against a budget of 0.1. Invisible text shifts too, because it was laid out in the fallback's metrics.
The 2024 Web Almanac gives the distribution: web fonts on 87% of sites, swap on 45% of pages, block on 23%, auto on 9% and optional on under 1%, and the ten most common block faces are all icon fonts. Where a Shopify store lands in that table is decided by which files the theme asks for, where in the layout it asks, and whether any come from a host that is not Shopify's.
The font picker decides the files, and the theme decides how they are declared
A theme font is a font_picker setting, which returns a font object with a family, weight, style, fallback families and a system? flag. The font_face filter turns it into a @font-face rule with a WOFF2 and a WOFF source; font_url returns the WOFF2 address on its own; font_modify returns the bold or italic variant of the same family, where the family has one, so it can be declared as well. Every variant is its own rule and its own file.
Two things about those files matter. Shopify's platform page says library fonts are served under /cdn/fonts/ on the domain that served the page, so they reuse the connection the HTML came over. On Shopify's own Dawn demo store the body face arrives from the store's domain as font/woff2 with a one-year immutable cache header, and the same file answers on fonts.shopifycdn.com, the host Dawn opens a preconnect to and, on that store, never requests from. And the font library files carry Basic Latin, Latin-1 Supplement, Latin Extended-A and the currency symbols, which covers French and German accents and the euro sign and does not cover Arabic or Cyrillic; a store that sells in those scripts renders them in the fallback whatever the picker says.
The third thing costs the most. font_face adds no font-display unless you pass one, so a theme that calls it bare gets auto, which Shopify's note says Chrome treats as block: invisible text for up to three seconds. The fix is one argument:
{{ settings.type_body_font | font_face: font_display: 'swap' }}
The count is the other number to know. Dawn's layout has two pickers and declares five faces: the body font, its bold, italic and bold italic through font_modify, and the heading font, all with swap. Horizon's styles snippet has four pickers, body, subheading, heading and accent, declares the same four variants of each, and keeps a running list of family, weight and style so a family shared between pickers is declared once. On Shopify's Horizon demo store that comes to seven @font-face rules for two families; on the Dawn demo, three for one. The filter emits nothing for a variant the family lacks, and a rule only becomes a request when a line of text uses that variant.
Then the sizes, which the picker does not show. The Almanac's median WOFF2 file is 39 KB, the 75th percentile 75 KB and the 90th 95 KB. From the two demo stores: Assistant 400 is 12 KB, Inter 400 is 39 KB, Bricolage Grotesque 700 is 90 KB. Shopify's self-hosting guide draws the line at one or two families, two to four weights per family, and says more than six files impacts performance; a heading in a display face and a body in a second family with four variants each is eight before an app adds an icon font. The system fonts are the zero. system_ui_n4, sans_serif_n4, serif and mono are picker handles, font_face returns an empty string for them, font_url returns nothing, and the text paints in the device's own face with no request at all.
Where the declaration sits in the layout decides when the request starts
Shopify streams most storefront pages in two parts. The platform page describes it: everything in the layout above {{ content_for_header }} is sent as soon as it renders, the browser parses that head and requests the stylesheets, fonts and scripts it finds there while Shopify is still rendering the sections, and the rest arrives when they finish. A @font-face rule below the tag is not seen until the sections are done, which Shopify's ordering note puts at hundreds of milliseconds of Liquid on a heavy product page. The note names the two reference themes as the two cases: Horizon renders its font preloads and declarations above the tag; Dawn puts content_for_header early in the head and its font_face block, base.css and its preloads below it, so on a streamed page they wait.
That is a discovery problem, and preload is the tool for discovery. Web.dev's LCP guide says a text element set in a web font is discoverable by the preload scanner only when the font is preloaded in the HTML or in a Link header; otherwise the browser has to finish the stylesheet first. Both reference themes preload, differently. Dawn writes a plain <link rel="preload" as="font"> for the body and heading files. Horizon's fonts snippet pipes each picker through font_url and preload_tag, guarded by system?, with fetchpriority: 'low':
{%- unless settings.type_body_font.system? -%}
{{ settings.type_body_font | font_url | preload_tag: as: 'font', type: 'font/woff2', fetchpriority: 'low' }}
{%- endunless -%}
The difference is where the hint travels. preload_tag writes the <link> and adds the URL to the response's Link header, and Shopify's preload note says the header becomes a 103 Early Hints response, which the browser receives before the HTML. A hand-written tag is found when the parser reaches it. On the Dawn demo store's document response the Link header carries two stylesheets and the logo, and no font.
The header has a budget. Shopify sends at most ten preload entries per response: the render-blocking scripts and stylesheets it preloads on its own come first, then stylesheet preloads, then fonts, then scripts, then images, and anything past the tenth is dropped. A head with ten render-blocking resources leaves no slot for a font and none for the hero image, which sorts last. The note's rule is two preloads of your own at most, tested before and after, and web.dev adds the reason: a preload takes bandwidth from whatever else is loading and ignores unicode-range, so a preloaded font downloads on a page that would never have needed it. Two faces above the fold is the right number, the heading and the body regular. The bold and the italic are found from the stylesheet when a line needs them.
A face from outside the library costs a connection before it costs a byte
The library has around 260 families and a licensing gap: the fonts page says some faces cannot be included, and a brand typeface bought from a foundry is not among them. The two routes out are a hosted service or files in the theme, and they load differently.
A service costs connections. A stylesheet from one host and files from a second is two DNS lookups and two TLS handshakes on a cold cache, and since browsers partitioned their caches by site, the Almanac notes, a shared font host no longer means a font already on the device. Web.dev is candid that the field data does not always favour self-hosting, and that it wins when the host is a CDN over HTTP/2; Shopify's is both, and its self-hosting guide ranks the three arrangements: files on the Shopify CDN fastest, a service with preconnect and an asynchronous stylesheet in the middle, a service loaded by its default <link> slowest.
Self-hosting is five steps. WOFF2 only; web.dev's size guide says its Brotli compression is up to 30% better than WOFF and that EOT and TTF are no longer needed. The files go into the theme's assets folder through the CLI or the GitHub integration rather than the admin code editor, which the fonts page warns can corrupt them; a file uploaded in the admin belongs in Content, Files, and is referenced with file_url instead. The @font-face rules go inline in the layout, above content_for_header, with asset_url for a versioned address and font-display: swap, and the one face the first screen needs gets a preload_tag:
{{ 'brand-regular.woff2' | asset_url | preload_tag: as: 'font', type: 'font/woff2' }}
<style>
@font-face {
font-family: "Brand";
font-weight: 400;
font-style: normal;
font-display: swap;
src: url('{{ "brand-regular.woff2" | asset_url }}') format('woff2');
}
</style>
A font preload has to be CORS-enabled even from the same origin, and preload_tag adds crossorigin="anonymous" itself when as is font, so do not pass it. And the file is served byte for byte, so subsetting is your job: the Almanac's self-hosted WOFF2 median is 39 KB against 16 KB for the same format from Google Fonts, and its explanation is that the service subsets and the self-hosters do not. A variable font replaces the regular, bold and both italics with one larger file, which web.dev says pays off only when the page uses several weights.
Where a licence forbids self-hosting, the guide gives the fallback: preconnect to both of the service's hosts, the second with crossorigin, load the stylesheet asynchronously, and request the two weights the page uses rather than nine. An icon font is a face like any other, usually arriving from an app with block, and web.dev's answer is SVG, because the fallback glyph for an icon is a different icon.
Check it in ten minutes on the product page
Open the best-selling product page in Chrome with DevTools on the Network panel, reload, and filter by Font. Four readings. The number of requests, against six. The domain column: every row should be the store's own domain under /cdn/fonts/ or /cdn/shop/t/, and any row from a service host is a connection the page paid for. The size column, against the 39 KB median and the 75 KB line. And the waterfall: a font that starts after the stylesheets finished was discovered late, and one that starts before the HTML finished came from a Link header.
Then the declarations. View the page source and search for @font-face: count the rules, and read whether each carries font-display. A rule without it is auto, and in Chrome that is block. Read the document's Link response header in the Headers tab of the first request, or from a terminal, and count its preload entries against ten:
curl -sIL "$URL" | grep -i '^link:' | tr ',' '\n' | grep -c 'rel="preload"'
Then the swap. In the Performance panel, throttle the network to slow 4G, record a reload, and read the Layout Shifts track for a shift that lands as the font requests finish; the Insights sidebar lists a font display insight when the value is missing, and Chrome's insight page says it passes with swap or optional. Finally PageSpeed Insights on the same URL, mobile: the LCP breakdown says whether the LCP element is text, and if it is, how much of the metric is resource load delay, which is the font being found late. The field figure at the top is the one Search Console reads, over 28 days; the lab run shows the change, the field is where it counts.
Fix it in this order, and the first pass is an afternoon
First, font_display: 'swap' on every font_face call in the layout, and font-display: swap in every hand-written rule. Five minutes, and it is the one change that moves LCP and CLS at once. If the body text is not part of the brand, optional for that family alone gives it 100 milliseconds and otherwise keeps the fallback for the visit, with no swap and no shift.
Second, position. Move the {% style %} block that declares the faces, and the preloads, above {{ content_for_header }}, after reading the ordering note for the three things that change when you do: scripts that read Shopify.* while parsing, stylesheet specificity ties, and deferred script order. Replace hand-written preload links with preload_tag, keep to two, and confirm in the Link header that both survived the cap. An hour for a developer.
Third, the count, which is theme settings rather than code. One family for headings, one for body, and check whether the italic and bold italic variants appear on the product page at all; a display face that only sets the hero does not need four. Fifteen minutes in the theme editor, then the Network panel again.
Fourth, the host. A service font becomes a subsetted WOFF2 in assets with the block above, or, where the licence says no, two preconnect hints and an asynchronous stylesheet. An afternoon.
Fifth, the swap itself, if the Layout Shifts track still shows one: a unitless line-height on the body and a fallback @font-face with size-adjust and the override descriptors, measured per pair, which is the font section of Shopify cumulative layout shift. If the largest element on the first screen is the gallery rather than a heading, the font is not the LCP resource and the loading half is in Shopify hero image size; the bytes it competes with are in Shopify page weight. If you would rather have all of it measured, ranked against everything else on the store and written up, that is the Store Teardown: €750, five days, and the document is yours whatever you decide to do with it.