Custom fonts give an iOS app character. Brand consistency, a differentiated UX. Used poorly, they’re a performance cost. When I added a custom font to Dentii, launch time jumped 200ms, with visible jank.
I found and fixed three main pitfalls, and restored launch time. Here’s the experience.
Pitfall 1: too many font files
A typical font ships as multiple files: Regular, Bold, Italic, Black, Light, Medium, Black Italic, Light Italic. Eight files, 500KB to 2MB each.
All of them get bundled into the binary. App size grows, and at launch each one is registered with the font system.
Fix: only ship the weights you actually use.
Say your project uses two weights (Regular, Bold). Don’t include the others in the binary. Don’t drop them in the asset catalog, don’t list them under UIAppFonts in Info.plist.
I saved 3 to 4 MB of binary size that way. Launch time dropped by 80ms.
Alternative: variable fonts. One file, programmatic weight. GeistVF.ttf (Geist Variable) packs every weight into a single file.
Text("Hello")
.font(.custom("GeistVF", size: 16))
.fontWeight(.bold) // Automatic variable axis adjustmentMost modern typefaces have a variable version. Prefer it.
Pitfall 2: font registration bottleneck
At iOS app launch, custom fonts go through a registration pass. With 5+ variants, the delay is noticeable.
Automatic registration at startup:
// Info.plist UIAppFonts array
<key>UIAppFonts</key>
<array>
<string>GeistRegular.otf</string>
<string>GeistBold.otf</string>
</array>This automatic registration is synchronous. It adds to launch time.
Alternative: async registration.
Leave fonts out of Info.plist and register them programmatically:
func registerFonts() {
let fontURLs = Bundle.main.urls(forResourcesWithExtension: "otf", subdirectory: nil) ?? []
fontURLs.forEach { url in
var error: Unmanaged<CFError>?
CTFontManagerRegisterFontsForURL(url as CFURL, .process, &error)
}
}
// At app launch, async
Task.detached(priority: .background) {
registerFonts()
}Register on a background thread. Main thread stays fast.
Careful though: fonts must be ready before the first UI render. Race conditions are possible.
Safer path: register synchronously for the first screen’s fonts, async for the rest.
Pitfall 3: font metric mismatches
The default system font (SF Pro, San Francisco) has very well-tuned metrics. Switching to a custom font:
- Line height changes
- Leading (space between lines) differs
- Cap height lands somewhere else
- x-height doesn’t match
Result: text looks misaligned in the UI. Each SwiftUI Text component can end up at a different height.
Fix: custom UIFont extension.
extension UIFont {
static func geist(size: CGFloat, weight: UIFont.Weight = .regular) -> UIFont {
let fontName: String
switch weight {
case .bold: fontName = "GeistBold"
case .semibold: fontName = "GeistSemiBold"
case .medium: fontName = "GeistMedium"
default: fontName = "GeistRegular"
}
let font = UIFont(name: fontName, size: size) ?? UIFont.systemFont(ofSize: size, weight: weight)
// Metric override to match the system font
let metrics = UIFontMetrics.default
return metrics.scaledFont(for: font)
}
}Fallback: system font if the custom font didn’t load.
CSS-like approach with text styles
Custom font plus Dynamic Type integration in SwiftUI:
extension Font {
static func geistTitle() -> Font {
.custom("GeistBold", size: 28, relativeTo: .title)
}
static func geistBody() -> Font {
.custom("GeistRegular", size: 16, relativeTo: .body)
}
static func geistCaption() -> Font {
.custom("GeistMedium", size: 12, relativeTo: .caption)
}
}The relativeTo: parameter respects the user’s text size preference. Custom font plus accessibility.
Font subset generation
On larger projects, you can optimize custom fonts further.
A font file contains every Unicode glyph (Chinese, Arabic, symbols). Strip out the characters you won’t use and the file shrinks.
Tools:
– pyftsubset (Python fonttools)
– glyphhanger (Node.js)
pyftsubset GeistRegular.otf --unicodes=U+0020-007F,U+00A0-00FF --output-file=GeistRegular-subset.otfSubset for Basic Latin (English plus European). 60 to 70% size reduction.
For a localized app, be careful: include the Turkish characters in the unicode range.
FOUT vs FOIT
A web problem that applies to iOS too:
- FOIT (Flash of Invisible Text): text stays invisible until the font loads
- FOUT (Flash of Unstyled Text): text shows in the system font first, then swaps to the custom one
iOS default is FOUT. Text appears, then swaps when the font loads. Minor flicker.
If the flicker bothers you:
// Load font async, then show UI
func loadFontThenShowUI() {
CTFontManagerRegisterFontURLs([url], .process, true) { _, _ in
DispatchQueue.main.async {
self.showMainUI() // Font ready, now show UI
}
}
}Delays launch a bit, but kills the flicker.
Licensing implications
Watch out for custom font licensing:
- Google Fonts: free, open source
- Adobe Fonts: requires Creative Cloud subscription
- Commercial fonts (Gotham, Proxima Nova): per-platform license
- Custom/bespoke fonts: designer contract
Does the font’s license permit iOS app usage? Check. Some fonts allow web use but not native apps.
Benchmarking
Benchmark before and after font loading optimization:
- App launch time (Instruments)
- First frame render time
- Text rendering perf (scroll through a list)
On Dentii:
– Before: 850ms launch (mid-range phone)
– After: 650ms launch (200ms recovered)
Wrap-up
Custom font performance impact is real, but manageable. Three pitfalls:
1. Too many font files: include only the weights you use
2. Font registration overhead: go async when possible
3. Font metric mismatch: scale with UIFontMetrics
Variable fonts are the modern answer. Subset generation for size reduction. Verify licensing.
Custom fonts add brand value. The performance cost can be minimized with careful management.