Loading Fonts Dynamically With JavaScript FontFace API
Web developers have always faced a tradeoff between design and performance. Custom fonts help make interfaces feel more unique or professional, but they often delay page rendering or flash the wrong font before loading the right one. The FontFace API gives you a way to load fonts on your own terms using JavaScript. Instead of relying on CSS-only solutions like @font-face, you can control how and when fonts load, apply them dynamically, and avoid render-blocking behavior entirely.
How the FontFace API Loads and Applies Fonts
Font loading used to be something that lived almost entirely in CSS. You’d declare a @font-face, point it at a URL, and hope the browser figured out when and how to fetch and display it. That still works, but it gives you very little control. The browser decides the timing, handles errors silently, and blocks text from showing during the download. The FontFace API changes all of that. With just JavaScript, you can create fonts dynamically, load them when you're ready, and apply them without freezing your page layout or flashing fallback text.
What The FontFace API Actually Does
The FontFace API lets you create a font family in JavaScript. You give it a name and a source, and it returns a FontFace object. That object represents a font file that hasn’t been connected to the page yet. You decide when to load it and when it should start affecting what users see.
Here’s a basic example:
const face = new FontFace('Headline', 'url(https://medium.com/fonts/Headline.woff2)');
face.load().then(function(loadedFace) {
document.fonts.add(loadedFace);
document.body.style.fontFamily = 'Headline, sans-serif';
});This creates a font named Headline, downloads it, and then adds it to the page. Because the font isn’t connected to the page until you call document.fonts.add(), the browser shows the first render immediately and never blocks on that font. Nothing actually uses the font until you tell the browser to use it in a style.
The value here is that it treats the font like any other resource you’re loading manually. It gives you access to the status, lets you handle failure cases, and gives you full say over when it becomes active.
Why This Doesn’t Block Render
When you use a font in a CSS file with @font-face, the browser starts downloading it as soon as it sees the rule. If any visible element on the screen tries to use that font, the browser might delay rendering to avoid displaying text in the wrong font. This is what causes FOIT and FOUT. FOIT hides text while the font loads, and FOUT shows a fallback then switches when the real one arrives.
Using the FontFace API avoids that problem. When you create a font with new FontFace, the browser doesn't try to apply it immediately. It’s not connected to anything yet. That means layout can proceed with whatever fonts are already available. You can start the load at any point, even after the page has painted.
Then, when the font finishes loading, you can swap it in. If that causes the text to reflow, that’s fine, but it happens on your terms. You’re not waiting on the font just to get your content on screen.
Here’s an example that shows how you can avoid blocking:
const font = new FontFace('CustomFont', 'url(https://medium.com/fonts/custom.woff2)');
document.body.textContent = 'Loading font without blocking...';
font.load().then(loaded => {
document.fonts.add(loaded);
document.body.style.fontFamily = 'CustomFont, sans-serif';
});The page shows the text right away using the system font. When the custom font finishes loading, it replaces the existing one. That avoids any white screens or unexpected flashes during the first render.
Breaking Down How Font Loading Works Internally
Font loading with this API happens in several parts. First, you create a font object. That doesn’t start any network requests yet. It just sets up the structure and waits. The second step is to load it, which returns a Promise. This is where the actual download starts. Once that Promise resolves, the font is decoded, parsed, and ready to use. Then you add it to the document’s font list with document.fonts.add().
Here’s a slightly more detailed breakdown using code:
// Step 1: Construct the FontFace object
const font = new FontFace('BodyFont', 'url(https://medium.com/fonts/body.woff2)', {
weight: '400',
style: 'normal'
});
// Step 2: Begin the load
font.load().then(loadedFont => {
// Step 3: Add to the document
document.fonts.add(loadedFont);
// Step 4: Apply to an element
document.body.style.fontFamily = 'BodyFont, sans-serif';
});The font object itself is like a shell. It doesn’t download anything until you call .load(). That’s what triggers the network request. Once the browser fetches the file and parses the font data, it moves to a ready state. After that, you register it with document.fonts.add(), which attaches it to the page’s active font set. It’s only after that point that you can assign it to an element’s fontFamily.
The font only affects what’s on the page if you explicitly tell the browser to use it. This gives you a clean separation between font management and text rendering.
What About Fallback Fonts Or Timeouts
Fallbacks still work the same way they do in regular CSS. You can specify a list of fonts, and the browser will use whatever is available. This becomes especially useful when working with fonts that load after the initial render.
Say you start with a system font, like this:
body {
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
}Then, after your custom font loads, you update the fontFamily to include it at the front:
font.load().then(f => {
document.fonts.add(f);
document.body.style.fontFamily = 'CustomFont, -apple-system, BlinkMacSystemFont, sans-serif';
});This lets your content stay readable right away using the fallback. When the new font arrives, it takes over. The transition is smooth and doesn’t interrupt the page.
You can also set a timeout manually if you want to skip applying the font if it takes too long:
const timeout = new Promise((_, reject) => {
setTimeout(() => reject(new Error('Font load timed out')), 3000);
});
Promise.race([font.load(), timeout])
.then(loaded => {
document.fonts.add(loaded);
document.body.style.fontFamily = 'CustomFont, sans-serif';
})
.catch(err => {
console.warn('Using fallback due to font issue:', err);
});This way you can avoid long waits and keep your interface predictable. If the font takes too long, the fallback stays in place and the page stays readable.
Creating, Managing and Using Custom Fonts on the Fly
Loading fonts from URLs works well in most cases, but the FontFace API lets you go further. You can work with binary font data directly, change fonts based on user input or dynamic context, and control when text styles change based on font status. JavaScript gives you enough control to delay, track, or respond to font loading however you need. This opens up use cases where fonts aren’t just part of the stylesheet but behave more like data assets tied to interaction, access, or runtime logic.
Making Fonts From Binary Data Instead Of URLs
The second argument to the FontFace constructor doesn’t need to be a url() string. You can pass it a binary buffer directly. This makes it possible to load fonts from sources that aren’t part of the public assets folder. It could be an API call, an object in IndexedDB, or a file the user selects with an upload input.
Get Alexander Obregon’s stories in your inbox
Join Medium for free to get updates from this writer.
Let’s say you’ve fetched a font from a private API that returns an ArrayBuffer. Here’s how to apply it:
async function usePrivateFont(buffer) {
const privateFont = new FontFace('PrivateFont', buffer, {
weight: '400',
style: 'normal'
});
await privateFont.load();
document.fonts.add(privateFont);
document.body.style.fontFamily = 'PrivateFont, sans-serif';
}To load the font file from a fetch call:
fetch('/api/fonts/custom.woff2')
.then(response => response.arrayBuffer())
.then(usePrivateFont)
.catch(err => console.error('Font fetch failed', err));This way you don’t need to rely on public font files or open font directories. The font can live behind authentication or be generated on demand. The format still needs to be a supported binary type like WOFF, WOFF2, or TTF.
This also works with user-supplied font files. A file input gives you a File object, and reading that as an ArrayBuffer gives you the input you need.
document.querySelector('#font-input').addEventListener('change', async (event) => {
const file = event.target.files[0];
const buffer = await file.arrayBuffer();
usePrivateFont(buffer);
});That lets users bring in their own fonts during runtime and apply them immediately, without any server involvement at all.
Waiting For Fonts Before Rendering Text
If your font is tied to a specific design layout, you might want to hold off on applying a style until the font is ready. This avoids awkward transitions where text appears in one font then reflows into another. You can hold the switch until the new font has finished loading and is safely applied.
A good pattern is to define your fallback fonts first in CSS and then shift to the custom font using a class once it’s ready.
body {
font-family: system-ui, sans-serif;
transition: font-family 0.2s ease;
}
body.font-ready {
font-family: CustomBody, system-ui, sans-serif;
}Then update the class only after the font is added and confirmed ready:
const font = new FontFace('CustomBody', 'url(https://medium.com/fonts/body.woff2)');
font.load().then(loaded => {
document.fonts.add(loaded);
document.body.classList.add('font-ready');
});The transition smooths the font change without hiding or replacing the text abruptly. This also works well with components or sections that wait on fonts before rendering text-heavy UI. You can apply the class at the element level if you want to limit the switch to part of the page.
FontFaceSet And Tracking All Fonts
The browser keeps track of every active font using a special object called document.fonts. This is a FontFaceSet, and it reflects the set of fonts currently registered to the page. Anything you load with FontFace and add using document.fonts.add() ends up in this set.
You can wait for all fonts in that set to finish loading using the .ready property. This returns a Promise that resolves when everything in the queue is done.
document.fonts.ready.then(() => {
console.log('All fonts have finished loading');
});If you just want to check whether a specific font is available before applying it, use the check() method.
if (document.fonts.check('18px CustomBody')) {
console.log('Font is active and can be used now');
}That gives you a way to guard against applying fonts that aren’t ready yet. You can poll that if needed, or pair it with your own loading logic to wait just long enough.
You can also inspect the fonts that have been added:
document.fonts.forEach(font => {
console.log(font.family, font.status);
});That gives you the current family name and whether each font is loading, loaded, or failed.
Using Font Loading Events
Besides polling and checking font status, the FontFaceSet object exposes experimental events (loading, loadingdone, loadingerror) that are supported in Chromium-based browsers and Firefox but not yet in Safari. You can attach event handlers to get updates about font load progress without manually tracking each one.
To run code after all active fonts have finished:
document.fonts.onloadingdone = () => {
console.log('All queued font loads are complete');
};This is helpful if you want to coordinate font loading with other parts of your layout. Maybe you’re building a complex UI where headings and layouts need to appear only after the font is applied. Using onloadingdone, you can keep those hidden until fonts are ready, then reveal them all at once. There’s also onloading and onloadingerror, though those are rarely needed unless you’re debugging or working with very large sets of font assets.
If you’re adding multiple fonts, the loading event fires only after all of them have completed:
const a = new FontFace('A', 'url(https://medium.com/fonts/A.woff2)');
const b = new FontFace('B', 'url(https://medium.com/fonts/B.woff2)');
Promise.all([a.load(), b.load()]).then(loaded => {
loaded.forEach(font => document.fonts.add(font));
});Then the onloadingdone event fires one time when both are finished. You don’t need to manually track each font's state unless you want to apply them one at a time.
Conclusion
The FontFace API lets you load and apply fonts without blocking layout or freezing the page. You can create them in JavaScript, wait for the data to load, and attach them only when ready. Fonts stay out of the render path until you apply them, and you decide exactly when that happens. This keeps layout stable and text readable while still giving you full control over how fonts behave.
- MDN Web Docs — FontFace API
- MDN Web Docs — FontFaceSet Interface
- MDN Web Docs — Using Web Fonts
- Can I use — FontFace API browser support
Thanks for reading! If you found this helpful, highlighting, clapping, or leaving a comment really helps me out.






