Anyone who generates posters or share cards in the browser has probably seen this one. The headline on the exported image comes out in a plain system font, the stylesheet names the right one, and the console is empty. I wrote a test page to see what the first fillText, the canvas call that paints a string in whatever ctx.font names, actually does there. The page declares a font with @font-face, the CSS rule that ties a family name to a file URL, then draws 秋季新品 全场八折 at 48px straight away. The file is a 3,755-character subset of Noto Serif SC, 1,046,216 bytes as WOFF2, served from a local server with caching off. After every draw I hash the canvas pixels and compare with two references, one drawn once the font was loaded and one in plain sans-serif. I ran it in Chromium 149, Chrome 155, Firefox 151 and WebKit 26.5 (macOS) on an Apple M4.
The first draw falls back and nothing complains
All four browsers matched the sans-serif reference on that immediate draw. Fallback means the generic family listed after the custom one in the font string, so the text is readable, just not in the font I asked for. At that moment document.fonts.status read loading, and document.fonts.check(), which answers whether a font string is usable right now, returned false. Across 68 page runs, 17 variants in each browser, the page threw no uncaught error, fallback runs included. I didn't go through console warnings one by one. Adding font-display: block or swap to the rule changed nothing for the canvas.
Read the top row first, then the two rows where only Firefox is red. Those pass in three browsers, which makes the problem look intermittent.
The font request goes out with that same draw
Then, with no waiting code at all, I redrew at 50, 200, 1000 and 2500 ms. From the 50 ms redraw onward all four browsers used the custom font. Resource timing shows where the font came from. In Chromium the font request was sent at 11 ms and finished at 35 ms, around the same moment as the first draw. No element on the page used the font. The load event variant agrees: the draw inside it was the fallback, and one 1500 ms later was correct.
titleCtx.font = '48px "ShareSerif", sans-serif';
titleCtx.fillText(headline, 16, 60); // fallback, no error
setTimeout(() => {
const hit = performance.getEntriesByType('resource')
.find(r => r.name.includes('share-serif'));
console.log(hit.startTime, hit.responseEnd);
}, 1000);
Two timestamps landing close together isn't proof, so I ran a control: declare the font, then do nothing for 2 seconds. Resource timing had no font request at all in that window, in all four browsers, three runs each. The first fillText at the 2-second mark drew the fallback, the request went out 2 to 32 ms after it, and a redraw 300 ms later used the custom font. So the draw asks for the font and doesn't wait for it. I only measured this from outside. I didn't read how any engine schedules the fetch.
A slower font makes the early redraws fall back too
The 50 ms success says more about my local server than about canvas. I delayed the font response by 800 ms and reran it. The immediate draw and the 50 ms and 200 ms redraws were the fallback in all four browsers, and the custom font appeared from the 1000 ms redraw. My first instinct for a fix had been a short setTimeout, and on the undelayed page it would have passed every time. A timeout only encodes a guess about how long the transfer takes.
Waiting for the font by name was correct in every run
document.fonts.load() takes a font string and, optionally, the text you plan to draw, and returns a promise that resolves once the fonts for it are loaded. Awaiting it before the draw gave the custom font in all four browsers. Pacifico for Latin text and the full 11.6 MB Noto Serif SC file behaved the same.
await document.fonts.load('48px "ShareSerif"', headline);
titleCtx.fillText(headline, 16, 60);
To see how a shipping editor handles the same gap I used ImgIng (https://imging.ai/) in Chromium 149. Its online design tool has text layers whose fonts download on first use, and the panel says the Korean one is about 4.5 MB. I slowed that font's response, selected Noto Sans Korean and clicked export about 0.2 seconds later. The file was written only after the font response arrived. The PNG was byte for byte the same as one exported after the font settled: 39,765 bytes each, against 39,854 bytes for the system-font export. That is one flow, one font, one browser, and I haven't looked at how it is written.
The status bar at the bottom left says the layer font is downloading, and the preview is still in the system font with the layer 648 wide. Once the font arrived it measured 677.
I can't explain the two rows where only Firefox is red. With a preloaded font it drew the fallback right after the preload finished and the custom font 1000 ms later, while the other three were correct straight away. I haven't looked into why. Phones, Windows, release Safari and Edge are untested. Noto Serif SC and Pacifico are both SIL OFL 1.1. To check your own page, log the font's resource timing entry next to the time of your first draw, then delay the font by a second and run it again.

Top comments (0)