DEV Community

hao jia
hao jia

Posted on

FontFace loaded, check() said true, and my canvas still drew the fallback font

Picture a share-card generator: a product photo, a price, and one promo line drawn with fillText. The line is meant to be in a display typeface, and the PNG comes out in plain sans-serif. Nothing throws. I wanted to know which step I could get wrong without any error, so I wrote a test page and took the script route one step at a time.

The script route is the FontFace constructor, which describes a font file in JavaScript instead of in a CSS @font-face rule. new FontFace(name, src) creates the description, .load() downloads and parses the file, and document.fonts.add() puts it into the page's font set, the list consulted when a font name shows up in ctx.font. I drew "秋季新品 全场八折" at 48px with a 3,755-character subset of Noto Serif SC (1,046,216 bytes as WOFF2), and "Autumn Sale 2026" with Pacifico, both open fonts under SIL OFL 1.1. To tell what had really been drawn, I hashed the canvas pixels and compared them with two references: one with the font known to be ready, one with the fallback, meaning the generic family at the end of the font string. The browsers were Chromium 149, Chrome 155, Firefox 151 and WebKit 26.5 on one Apple M4 Mac running macOS. No phones, Windows, release Safari or Edge.

Loading without add draws the fallback font

First I created the FontFace, awaited load(), and drew without calling add. All four browsers drew the fallback font. Worse, face.status was "loaded", and document.fonts.check(), the call that is supposed to answer "can I draw with this font string right now", returned true. A draw guarded by check() goes ahead and the text is still wrong. I then asked check() about 48px "NoSuchFont9", a name I never declared anywhere, and it said true in all four as well. I picture a loaded font that was never added as a library book that has been delivered but not catalogued: it is in the building, and a catalogue search still comes back empty. Calling document.fonts.add(face) and drawing again gave the custom font in all four.

Adding without awaiting load gets the first draw wrong

Next I swapped the mistake: new FontFace, then add straight away, no load(), then draw. Fallback font again, in all four. This time the page fixed itself: when I drew again 1500 ms later, with nothing else changed, the custom font was there. A generator that renders once and exports would ship that first drawing. Across the whole test, 68 page runs over the four browsers, no uncaught error was thrown, and that includes every run that drew the fallback. I only listened for thrown errors and did not read through console warnings.

All three steps in order drew the custom font

const face = new FontFace('PromoDisplay', 'url(/fonts/promo-display.woff2)');
await face.load();          // step 2: download and parse
document.fonts.add(face);   // step 3: register with the page
ctx.font = '48px "PromoDisplay", sans-serif';
ctx.fillText('Autumn Sale 2026', 24, 80);
Enter fullscreen mode Exit fullscreen mode

In this order, all four browsers drew the custom font on the very first draw.

A Worker needs its own three steps before the first draw

The same three steps come back when the drawing moves into a Web Worker with OffscreenCanvas, a canvas that works off the main thread. A font already loaded and added on the main thread did nothing for the Worker, which drew the fallback in all four browsers. Running new FontFace, load() and self.fonts.add() inside the Worker before its first draw gave the custom font in all four.

Worker results in four browsers

Rows three and four are the ones to read. When the Worker drew once before the font was loaded, then loaded it and redrew on the same canvas with the same font string, Chromium, Chrome and WebKit kept drawing the fallback, and only Firefox switched. On a new OffscreenCanvas in that same Worker, Chromium and Chrome still drew the fallback, while WebKit switched to the custom font there. I did not look into why.

To see how a finished tool deals with the wait, I used ImgIng (https://imging.ai/) and its online design editor in a desktop browser. Some text-layer fonts there are downloaded when you pick them, and the menu states the size up front, about 4.5 MB for the Korean one. I slowed the font response down, picked Noto Sans 한국어, and clicked export about 0.2 seconds later. The file was written only after the font response arrived, and the PNG was byte-for-byte identical to one I exported after the font was in place, 39,765 bytes each. The system-font export was 39,854 bytes. This was one flow with one font in Chromium 149, and I did not read how it is written inside.

Two exported PNGs compared

The top strip is the system-font export and the bottom one was exported right after picking the Korean font. The text is "가을 신상품 세일", both cropped around it.

In my own drawing code I now await load(), call add, and only then touch the canvas, and I no longer use check() as the gate. The Worker redraw behavior is the part I still cannot explain.

Top comments (0)