Real-world bug stomping, and a deep dive on web font loading
TL;DR
In the Organic Growth team at Wise 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 (English version), which gives web searchers the exchange rate for a currency route. It was trying 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 fixing one@font-face
and removing the second.
Read on to learn more about how we discovered and fixed the issue, plus when and how browsers decide to download font files (hint: they’re pretty intelligent).
P.S. Interested in working at Wise? We’re hiring! Check out open Engineering roles.
The app: Currency Converter
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 thousands 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, 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.
Font file not found
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:
- [️ ] What caused the failing request?
- [ ]️️ Why did it make a second request on the Korean page?
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.
- ✅ 200:
https://wise.com/currency-converter-assets/fonts/TW-Averta-Regular.woff2
- ❌ 404:
https://wise.com/kr/fonts/TW-Averta-Regular.woff2
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.
We looked at the page source and found the culprit in our inlined Critical CSS.
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. We then removed the second @font-face
to clean things up.
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.
- [x]️️ What caused the failing request?
- [ ]️️ Why did it make a second request on the Korean page?
When does the browser download a font?
Browsers only download a font file when it’s needed to display text found on the page—simply declaring a font file doesn’t force a download. web.dev summarises the behaviour:
1. The browser requests the HTML document.
2. The browser begins parsing the HTML response and constructing the DOM.
3. The browser discovers CSS, JS, and other resources and dispatches requests.
4. 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.
5. 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
:
- Find the text
Currency Converter
- Check the text against the CSSOM
- Determine the text should be displayed in Averta
- Use the last listed*
@font-face
definition for Averta to download the font file - Paint!
In the failing example, /kr/currency-converter
:
- Find the text
환율계산기
(“Currency Converter”) - Check the text against the CSSOM
- Determine the text should be displayed in Averta
- Use the last listed*
@font-face
definition for Averta to download the font file - Discover this font file does not contain the necessary glyphs to paint the text
- Try to use the second-to-last
@font-face
definition for Averta
—1. Fail to download the first relevantsrc
file, thewoff2
file (see screenshot below)
—2. Fall back to but still fail to download the nextsrc
file, thewoff
file
—3. Fall back to but still fail to download the last relevantsrc
file, thettf
file - Determine that all options to get the specified font have been exhausted
- Fall back to the next font specified by CSS, the Operating System’s default
sans-serif
font - Paint!
*Falling back in reverse order
It 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.
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 know before downloading 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.
- [x] What caused the failing request?
- [x] Why did it make a second request on the Korean page?
Multiple @font-face
s
It’s important to know that @font-face
was designed to be used multiple times for the same font.
Variants
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
:
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.
Character sets
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:
A common way to split up fonts is by script, so you 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.
Other special tricks
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.
Conclusion
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 to join us? We’re hiring. Check out our open Engineering roles.