Feb 2021—Real-world bug stomping, and a deep dive on web font loading
In the Organic Growth team at Wise (née TransferWise) we manage web apps for SEO—landing pages generated to rank for keyword searches. We discovered a frontend issue in the Korean version of our Currency Converter app, which helps web searchers know the exchange rate for a currency route. It was attempting to load our brand font from the wrong location. Confusingly, this 404 occurred after successfully loading the font from the correct URL.
This only happened with languages where the font file did not contain the glyphs necessary for that language. For English, the browser could render all the text from the first file it downloaded. With Korean, the browser downloaded the font, realised it didn’t contain Korean characters, then went back to another @font-face
definition and tried to download that from the erroneous URL. We solved this by transforming the first @font-face
, and removing the second.
P.S. Interested in working at Wise? We’re hiring! Check out open Engineering roles.
One of the apps we manage in the Organic Growth team is a Currency Converter. The SEO angle of the project is to help people who search Google for "Convert GBP to USD" or "AED INR exchange rate". They get an answer to their question, and in the process learn about our product and hopefully decide to make a transfer with us. This brings in hundreds of new paying customers every month.
We recently released the Currency Converter in a few new languages, in order to gauge interest in Wise in new markets. In particular for SEO, it's good to start building a search presence in a market as a first initiative, so your brand is already seeded into search results if you decide to go further. The Currency Converter is available in languages like Thai, Korean, Greek, Finnish, and many more. Our brand font doesn't support all these languages, and in those cases we're happy to use the OS default.
After launching the Korean Currency Converter, we noticed some 404s in the network tab for font files. Confusingly, these 404s occurred after a separate, successful request for the same filenames. The font was loaded, and working. We had two questions to answer:
The first clue was that the failed request was an incorrect location. This app's static assets are served from our CDN in a /currency-converter-assets
folder.
Note: We should have been referencing a shared font file so future Wise pages don't re-download the same font. This has also been corrected.
Status | URL |
---|---|
✅ 200 | https://wise.com/currency-converter-assets/fonts/TW-Averta-Regular.woff2 |
❌ 404 | https://wise.com/kr/fonts/TW-Averta-Regular.woff2 |
We looked at the page source and found the culprit in our inlined Critical CSS.
<style type="text/css">
@font-face {
font-family: Averta;
/* fallback fonts removed for brevity */
src: url(../fonts/TW-Averta-Regular.woff2) format("woff2");
font-weight: 500;
}
/* ...snip */
@font-face {
font-family: Averta;
src: url(/currency-converter-assets/fonts/TW-Averta-Regular.woff2) format("woff2");
font-weight: 500;
}
</style>
We've been experimenting with Critical CSS recently. Before we started inlining these styles, they were in a .css
file where ../fonts/
was indeed the correct folder. For our initial implementation of Critical CSS, the quickest solution was to take the relevant contents from the .css
file and drop them into the page. This left the now-incorrect relative path in the styles. The easy fix for this was to add the second @font-face
definition, knowing that with the CSS cascade the second would take precedence.
Now that we could see the browser was requesting the incorrect font in some cases, we took the extra step to replace ../fonts
with /currency-converter-assets/fonts
in our Gulpfile.
const criticalCssBundle = () =>
src([
// Neptune is the Wise design system, and the source of the @font-face
"./node_modules/@transferwise/neptune-css/dist/css/neptune-core.css",
// Several other files
])
.pipe(replace("../fonts/", "/currency-converter-assets/fonts/"))
.pipe(concat("currency-converter-critical.css"))
.pipe(dest(`${dist}css/`));
With those issues addressed, the bug was fixed. The 404 disappeared. But... why? Why did it make a second request? Why did this only happen for some languages? To answer this question, we have to understand more about when and why browsers download fonts.
Browsers only download a font file when it's needed to display text found on the page. web.dev summarises the behaviour:
- The browser requests the HTML document.
- The browser begins parsing the HTML response and constructing the DOM.
- The browser discovers CSS, JS, and other resources and dispatches requests.
- The browser constructs the CSSOM after all of the CSS content is received and combines it with the DOM tree to construct the render tree.
- Font requests are dispatched after the render tree indicates which font variants are needed to render the specified text on the page.
- The browser performs layout and paints content to the screen.
Source: Optimize WebFont loading and rendering - web.dev, emphasis added
So the browser figures out where all the pieces of text are, and checks that against the CSS to decide what font to paint with. The Korean page went through a multi-step fallback process, as part of step 4 in the above list.
In the working example, /gb/currency-converter
:
Currency Converter
@font-face
definition for Averta to download the font fileIn the failing example, /kr/currency-converter
:
환율계산기
@font-face
definition for Averta to download the font file@font-face
definition for Avertasrc
file, the woff2
file (see screenshot below)src
file, the woff
filesrc
file, the ttf
filesans-serif
fontIt was interesting to us that the browser appeared to fall back through multiple @font-face
declarations in reverse order. We confirmed this by switching the order, so the correct URL was defined first and then the incorrect relative URL.
<style type="text/css">
@font-face {
font-family: Averta;
font-weight: 500;
src: url(/currency-converter-assets/fonts/TW-Averta-Regular.woff2);
}
/* ...snip */
@font-face {
font-family: Averta;
/* fallback fonts removed for brevity */
src: url(../fonts/TW-Averta-Regular.woff2);
font-weight: 500;
}
</style>
In that case, on /gb/currency-converter
the browser first attempted to load the ../fonts
URL, which failed, and then loaded the correct URL. This is opposite to Jake Archibald's description when talking about using multiple @font-face
declarations.
His core point still stands, however. This is worse for performance because the browser has to download the font to determine whether it can paint the given text with that font.
The best solution would be to specify Averta's unicode-range
, so the browser can determine whether a given string of text would be supported by the font file defined in the @font-face
declaration. More on that in Character sets below.
@font-face
sIt's important to know that @font-face
was designed to be used multiple times for the same font.
Here at Wise we use this to define variants, like bold and semi-bold text. The browser is capable of taking a base font and transforming it into those variants but often they don't look great. Instead, we create multiple @font-face
declarations for Averta to match a font file to a font-weight
:
@font-face {
font-weight: 500;
font-family: Averta;
src: url(/currency-converter-assets/fonts/TW-Averta-Regular.woff2);
}
@font-face {
font-weight: 600;
font-family: Averta;
src: url(/currency-converter-assets/fonts/TW-Averta-Semibold.woff2);
}
@font-face {
font-weight: 800;
font-family: Averta;
src: url(/currency-converter-assets/fonts/TW-Averta-Bold.woff2);
}
Now when the browser finds some text in a p > strong
tag, it can see from the CSSOM that the text should be painted as Averta with a font-weight of 800. It finds the third @font-face
and triggers the font to be downloaded. If no bold text is used on the page, the font isn't downloaded. On the flip side, if it finds italic text (which we don't use in our design system), the browser won't find a matching @font-face
and will manually transform the next-closest font to pseudo-italics.
A font file that supports every language—every glyph in every character set—would be massive. Instead, developers can set @font-face
for a specific set of Unicode characters. The following example is from CSS-Tricks:
@font-face {
font-family: 'MyWebFont';
src: url('myfont.woff2') format('woff2');
unicode-range: U+00-FF; /* Define the available characters */
}
A common way to split up fonts is by script, so you can have one font file for Latin characters, one for Cyrillic characters, one for Chinese glyphs, one for Devanagari languages like Hindi, and so on. The developer can create a @font-face
declaration for each of these separated font files with the same font-family
, and the browser will figure out which ones are needed for the current text of the page.
This feature can be leveraged in interesting and novel ways. Jake Archibald uses it to replace just two characters from a font he otherwise likes.
I've also seen it used by font foundries to discourage pirating. The copyrighted font is split up into multiple files with arbitrary sets of characters, and provides developers with a JavaScript loader that will download the files from the foundry. This was the knowledge that unlocked the source of this bug for us, knowing that one font can be broken up into multiple files and requested at runtime.
Our major takeaway from this experience was to test more rigorously before releasing new languages. We learned that the browser is actually quite smart about downloading font files, and that we should optimise the user experience by adding a unicode-range to our font face definitions.
P.S. Interested in working with Wise? We’re hiring! Check out open Engineering roles.