# What to decide when registering fonts at runtime on iOS

> Bundled fonts need only an Info.plist entry, but downloaded or packaged fonts may suit runtime registration. This covers names, a registry, and file lifetime.

- Canonical: https://jaemyeong.com/en/blog/ios-runtime-font-registration/
- Published: 2026.06.18
- Updated: 2026.10.04
- Category: IT/기술
- Tags: #iOS, #Swift, #CoreText, #Font

A font that always ships inside the app never needs runtime registration. Add the file in Xcode and declare it under [UIAppFonts](https://developer.apple.com/documentation/bundleresources/information-property-list/uiappfonts) in `Info.plist`, and the system loads it at launch. [The guide to adding a custom font to your app](https://developer.apple.com/documentation/uikit/adding-a-custom-font-to-your-app) explains this method.

Runtime registration is worth considering in other cases: a font downloaded from a server, a font chosen by theme, language, or content, a font inside a Swift Package, a framework, or a separate bundle, a font list that changes after the build, and a font swapped in for tests and previews. The two methods are used together, depending on what is required.

What follows covers how the API behaves and what has to be decided in operation, with example code. How the API behaves comes from the Apple documentation. Managing registration in one place, the ownership of the file and the order of deletion, and the advice to organize everything around the file URL are not requirements of the documentation. They are my own design choices. I compiled and ran the example code on macOS on October 4, 2026, and the result is in the section "What running it on macOS showed." Results from running it in an iOS app and applying the font on screen are not in this post.

## Registration lasts only within the process

Passing a file URL to [CTFontManagerRegisterFontsForURL](https://developer.apple.com/documentation/coretext/ctfontmanagerregisterfontsforurl%28_%3A_%3A_%3A%29) puts the fonts in that file into descriptor matching. After that they can be used with `UIFont(name:size:)` or `Font.custom(_:size:)`. For a font used only inside the app, the scope is [CTFontManagerScope.process](https://developer.apple.com/documentation/coretext/ctfontmanagerscope/process). This registration disappears when the process ends, so it has to be done again on the next launch.

This is the simplest registration code.

```swift
import CoreText

func registerFont(at url: URL) throws {
    var error: Unmanaged<CFError>?

    let success = CTFontManagerRegisterFontsForURL(
        url as CFURL,
        .process,
        &error
    )

    if !success {
        if let error = error?.takeRetainedValue() {
            throw error
        }
    }
}
```

Three things are missing from this code. It does not check that the file exists, it does not find out the name of the font, and it has no handling for registering the same file twice.

## A file name is not a font name

Registering a file named `Pretendard-Regular.otf` does not guarantee that the font can be called by the name `Pretendard-Regular`. What [UIFont(name:size:)](https://developer.apple.com/documentation/uikit/uifont/init%28name%3Asize%3A%29) takes is a fully specified name.

The name is read from the file itself. [CTFontManagerCreateFontDescriptorsFromURL](https://developer.apple.com/documentation/coretext/ctfontmanagercreatefontdescriptorsfromurl%28_%3A%29) returns the descriptors, and [kCTFontNameAttribute](https://developer.apple.com/documentation/coretext/kctfontnameattribute) gives the PostScript name. One file can hold several faces, as in a `.ttc` file, so the result is an array.

This function collects the PostScript names from the descriptors in a file.

```swift
import CoreText

func postScriptNames(in url: URL) -> [String] {
    guard let descriptors = CTFontManagerCreateFontDescriptorsFromURL(url as CFURL) as? [CTFontDescriptor] else {
        return []
    }

    return descriptors.compactMap {
        CTFontDescriptorCopyAttribute($0, kCTFontNameAttribute) as? String
    }
}
```

During development, logging the names that were read makes it easy to confirm which name to call.

```swift
let names = postScriptNames(in: fontURL)
print("Font PostScript names:", names)
```

The output of an actual run is in the section "What running it on macOS showed." The names that were read are gathered in one place, such as a design system or a registry. Writing the string directly in each view makes it hard to replace a font or trace an error.

## An example that manages registration state in one place

Calling registration every time a cell is created, every time a SwiftUI `body` is evaluated, or every time a screen appears can cause duplicate registration errors and unnecessary cost. So one object is put in charge of registration. `RuntimeFontRegistry.shared` handles the file check, the name collection, and the record of registrations per URL.

First comes the part that holds the state. It stores the URL as the key and the array of names read from that file as the value. The three blocks that follow are the rest of this class. To combine them, remove the `// …` line and the closing brace below it from this block, then append the three blocks in order. The last block closes the class and declares the error type outside it.

```swift
import Foundation
import CoreText

final class RuntimeFontRegistry {
    static let shared = RuntimeFontRegistry()

    private var registeredURLs: [URL: [String]] = [:]
    private let lock = NSLock()

    private init() {}

    // …
}
```

The register method normalizes the key with `standardizedFileURL`, checks that the file exists, and checks that the descriptors and names are valid. It then looks up the cache under an `NSLock`. If the URL is already registered, it returns the stored names. If not, it registers with `.process` and puts the array of names into the cache.

```swift
    @discardableResult
    func registerFont(at url: URL) throws -> [String] {
        let url = url.standardizedFileURL

        guard FileManager.default.fileExists(atPath: url.path) else {
            throw FontRegistrationError.fileNotFound(url)
        }

        let names = try Self.readPostScriptNames(from: url)

        lock.lock()
        if let cached = registeredURLs[url] {
            lock.unlock()
            return cached
        }
        lock.unlock()

        var error: Unmanaged<CFError>?
        let success = CTFontManagerRegisterFontsForURL(
            url as CFURL,
            .process,
            &error
        )

        if !success {
            if let error = error?.takeRetainedValue() as Error? {
                throw error
            }
        }

        lock.lock()
        registeredURLs[url] = names
        lock.unlock()

        return names
    }
```

The unregister method calls CoreText with the same scope used for registration and removes the entry from the cache.

```swift
    func unregisterFont(at url: URL) throws {
        let url = url.standardizedFileURL

        var error: Unmanaged<CFError>?
        let success = CTFontManagerUnregisterFontsForURL(
            url as CFURL,
            .process,
            &error
        )

        if !success {
            if let error = error?.takeRetainedValue() as Error? {
                throw error
            }
        }

        lock.lock()
        registeredURLs.removeValue(forKey: url)
        lock.unlock()
    }
```

This is the part that reads the names, and the error type. It tells apart a missing file, a file that is not a font, and a file with no PostScript name.

```swift
    private static func readPostScriptNames(from url: URL) throws -> [String] {
        guard let descriptors = CTFontManagerCreateFontDescriptorsFromURL(url as CFURL) as? [CTFontDescriptor],
              !descriptors.isEmpty else {
            throw FontRegistrationError.invalidFontFile(url)
        }

        let names = descriptors.compactMap {
            CTFontDescriptorCopyAttribute($0, kCTFontNameAttribute) as? String
        }

        guard !names.isEmpty else {
            throw FontRegistrationError.missingPostScriptName(url)
        }

        return names
    }
}

enum FontRegistrationError: Error {
    case fileNotFound(URL)
    case invalidFontFile(URL)
    case missingPostScriptName(URL)
}
```

Some things are not handled in this example. Interpreting the error codes that CoreText returns is extra work. I recommend treating a repeated request for the same URL as success, and treating a different file with the same PostScript name as a conflict. A wrong or damaged file has to be handled separately from a duplicate or a name conflict.

Reading the code also shows some limits. `NSLock` is held only while the cache is looked up and while it is stored, and the CoreText call is outside the lock. So two simultaneous requests for the same URL can both enter registration. Also, when `success` is false but `error` is nil, the code does not throw and moves on to the next line. I read these two points from the code. I did not reproduce them by running it.

## A downloaded font needs a decision about the file's lifetime

If a file in a temporary location is registered, there is a risk that the system keeps a reference after the file is deleted. So the file is moved to a location the app manages, and registered after that.

This function creates a `RuntimeFonts` directory under `Application Support`.

```swift
func fontStorageDirectory() throws -> URL {
    let baseURL = try FileManager.default.url(
        for: .applicationSupportDirectory,
        in: .userDomainMask,
        appropriateFor: nil,
        create: true
    )

    let directory = baseURL.appendingPathComponent("RuntimeFonts", isDirectory: true)

    if !FileManager.default.fileExists(atPath: directory.path) {
        try FileManager.default.createDirectory(
            at: directory,
            withIntermediateDirectories: true
        )
    }

    return directory
}
```

This function copies the downloaded file into that directory and registers it with the registry. The file name is the last path component of `temporaryURL` as it is, and if a file with the same name exists, it is deleted before the copy.

```swift
func installDownloadedFont(from temporaryURL: URL) throws -> [String] {
    let directory = try fontStorageDirectory()
    let destinationURL = directory.appendingPathComponent(temporaryURL.lastPathComponent)

    if FileManager.default.fileExists(atPath: destinationURL.path) {
        try FileManager.default.removeItem(at: destinationURL)
    }

    try FileManager.default.copyItem(at: temporaryURL, to: destinationURL)

    return try RuntimeFontRegistry.shared.registerFont(at: destinationURL)
}
```

This install example does not unregister before deleting the existing file. That does not match the advice below to unregister before deleting. If the file at an already registered URL is replaced, the registry can also return the names it cached earlier. This is another limit read from the code. The descriptor check happens inside the registry after the copy, and there is no code that checks the integrity of the download, the license, or a name conflict.

What has to be decided in operation lies outside this example: when to delete the file, how to register on the next launch, the fallback and what the user sees when a download fails, the server response and distribution, cache invalidation, the relation to the app version, and the license review.

## What running it on macOS showed

I joined the Swift code of this post as it is, compiled it, and ran it. The run used Swift 6.4 on macOS 27.0.1. CoreText exists on macOS too, so registering and unregistering could be checked with no iOS device. There is no UIKit there, though, so the name was checked with `CTFontCreateWithName` in place of `UIFont(name:size:)`. The font was `PretendardStd-Bold.otf`, which was already on this Mac and was copied to a temporary folder. It was not installed in the system.

The code compiled with no warnings. Below is the output of a run with calling code added that checks whether the font can be found by name before and after registration.

```text
Font PostScript names: ["PretendardStd-Bold"]
before register: discoverable=false
registry.registerFont: ["PretendardStd-Bold"]
after register : discoverable=true
CTFontCreateWithName -> PretendardStd-Bold
registry.registerFont again (cached): ["PretendardStd-Bold"]
plain registerFont again: error com.apple.CoreText.CTFontManagerErrorDomain code=105
after unregister: discoverable=false
unregister again: error com.apple.CoreText.CTFontManagerErrorDomain code=201
missing file: fileNotFound(<url>)
```

Four things can be read from it.

- The font could not be found by name before registration, could be found after it, and could not be found again after unregistration.
- On the second registration, the registry returned the names it had cached. Registering the same URL again without going through the registry gave an error with code 105, the value of CoreText's `kCTFontManagerErrorAlreadyRegistered`.
- Unregistering twice gave an error with code 201 (`kCTFontManagerErrorNotRegistered`).
- A missing file was caught as `fileNotFound` before CoreText was called.

With a font file that is already installed in the system, the same run found the font by name before registration and after unregistration as well. The same font remains at another path. Code that judges registration by name cannot tell that case apart. `installDownloadedFont` was not called.

## Unregistering only removes the font from matching

Calling [CTFontManagerUnregisterFontsForURL](https://developer.apple.com/documentation/coretext/ctfontmanagerunregisterfontsforurl%28_%3A_%3A_%3A%29) takes the font out of descriptor matching. It should not be read as cleaning up `UIFont`, `CTFont`, `NSAttributedString`, and SwiftUI views that already exist.

The lifetime is decided by how the font is used. A font needed for the whole run stays registered. For a temporarily downloaded resource, an owner is decided and that owner unregisters it. Unregistering is attempted before the file is deleted, and if it fails, the deletion is postponed. The scope for registering and unregistering has to be the same, so a font registered with `.process` is unregistered with `.process`.

[CTFontManagerRegisterGraphicsFont](https://developer.apple.com/documentation/coretext/ctfontmanagerregistergraphicsfont%28_%3A_%3A%29) is the API that registers a `CGFont` object. For a font in a file, the Apple documentation points to URL registration. In the documentation as rechecked on October 4, 2026, this API is marked deprecated as of iOS 18.0 and macOS 15.0, with a note to use `CTFontManagerCreateFontDescriptorsFromData` or `CTFontManagerRegisterFontsForURL`. That designing storage, registration, unregistration, and the cache as one unit around the file URL is easier to handle is my own judgment.

The lifetime of `.process`, the choice between URL registration and `CGFont` registration, and the effect of unregistering on objects that already exist need to be checked against the SDK and OS version. Branching per error code, conflict handling, safe file replacement and cache invalidation, registration after a restart, and the fallback policy are only described in this post and not implemented in the examples. What the examples do contain is the per-URL cache and a simple file replacement.
