Optimizing Core Data Performance in SwiftUI

As your app grows and the dataset becomes larger, optimizing Core Data’s performance becomes critical to ensure a smooth and responsive user experience.

article

As your app grows and the dataset becomes larger, optimizing Core Data’s performance becomes critical to ensure a smooth and responsive user experience. Poor performance can lead to slow data fetching, memory issues, and unresponsive UI, especially when dealing with complex data models and large datasets. In this article, we’ll explore various techniques to optimize Core Data performance in SwiftUI, ensuring your app remains fast and efficient.


1. Using Background Contexts for Asynchronous Operations

One of the best ways to keep your UI responsive when working with large datasets is to move Core Data operations off the main thread. Core Data provides background contexts, allowing you to perform fetches, inserts, updates, and deletes in the background without blocking the main thread.

Here’s an example of how to use a background context in SwiftUI:



struct BackgroundFetchView: View {
    let backgroundContext = PersistenceController.shared.persistentContainer.newBackgroundContext()

    @State private var tasks: [Task] = []

    var body: some View {
        List(tasks, id: \.self) { task in
            Text(task.name ?? "Unknown Task")
        }
        .onAppear(perform: fetchTasks)
    }

    private func fetchTasks() {
        backgroundContext.perform {
            let fetchRequest: NSFetchRequest = Task.fetchRequest()

            do {
                let fetchedTasks = try backgroundContext.fetch(fetchRequest)
                DispatchQueue.main.async {
                    self.tasks = fetchedTasks
                }
            } catch {
                print("Failed to fetch tasks: \(error.localizedDescription)")
            }
        }
    }
}

In this example, we perform the data fetch in a background context, ensuring that the UI remains responsive. Once the tasks are fetched, we switch back to the main thread to update the UI.


2. Using Fetch Limits and Batch Sizes

When fetching large datasets, you can limit the amount of data fetched at once by setting fetch limits and batch sizes. This prevents loading the entire dataset into memory at once, reducing memory usage and improving performance.

Here’s an example of how to set a fetch limit and batch size:



@FetchRequest(
    entity: Task.entity(),
    sortDescriptors: [NSSortDescriptor(keyPath: \Task.createdAt, ascending: false)],
    fetchLimit: 20,
    batchSize: 10
) private var tasks: FetchedResults

In this example, we set a fetchLimit of 20, meaning only the first 20 records are fetched, and a batchSize of 10, meaning the data will be fetched in batches of 10 objects at a time.


3. Avoiding Faults and Using Faulting Effectively

Core Data uses a technique called faulting to improve performance. A fault is a placeholder for a managed object that hasn’t been fully loaded into memory yet. Fetch requests only load the object’s properties when they are accessed, which minimizes the memory footprint.

However, repeatedly firing faults for each object can hurt performance, especially when displaying a large list of objects. You can prefetch specific properties to avoid faulting when displaying data in the UI:



@FetchRequest(
    entity: Task.entity(),
    sortDescriptors: [NSSortDescriptor(keyPath: \Task.name, ascending: true)],
    fetchLimit: 50,
    relationshipKeyPathsForPrefetching: ["category"]
) private var tasks: FetchedResults

In this example, we use relationshipKeyPathsForPrefetching to prefetch the related Category objects when fetching tasks. This minimizes the number of faults fired when accessing related entities, improving performance when displaying the data in the UI.


4. Reducing Memory Usage with Batch Updates and Deletions

When performing large-scale updates or deletions, fetching all objects into memory before modifying or deleting them can lead to memory issues. Instead, you can perform batch updates or deletions directly at the database level without loading objects into memory.

Here’s an example of a batch update that marks all tasks as completed:



func batchUpdateTasks(context: NSManagedObjectContext) {
    let batchUpdateRequest = NSBatchUpdateRequest(entityName: "Task")
    batchUpdateRequest.predicate = NSPredicate(format: "isCompleted == NO")
    batchUpdateRequest.propertiesToUpdate = ["isCompleted": true]
    batchUpdateRequest.resultType = .updatedObjectIDsResultType

    do {
        let batchUpdateResult = try context.execute(batchUpdateRequest) as? NSBatchUpdateResult
        if let objectIDs = batchUpdateResult?.result as? [NSManagedObjectID] {
            for objectID in objectIDs {
                let task = context.object(with: objectID)
                context.refresh(task, mergeChanges: true)
            }
        }
    } catch {
        print("Failed to perform batch update: \(error.localizedDescription)")
    }
}

In this example, we update all tasks in the database to mark them as completed without fetching them into memory. After the update, we refresh the objects in the context to ensure they reflect the new changes.


5. Performing Batch Deletes

Batch deletions are similar to batch updates but allow you to delete large sets of data without loading each object into memory. Here’s an example of how to perform a batch delete:



func batchDeleteTasks(context: NSManagedObjectContext) {
    let fetchRequest: NSFetchRequest = Task.fetchRequest()
    fetchRequest.predicate = NSPredicate(format: "isCompleted == YES")

    let batchDeleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest)

    do {
        try context.execute(batchDeleteRequest)
        print("Batch delete successful!")
    } catch {
        print("Failed to perform batch delete: \(error.localizedDescription)")
    }
}

This example performs a batch delete of all completed tasks by executing a NSBatchDeleteRequest. The objects are deleted directly in the database, avoiding memory overhead.


6. Fetching Data Asynchronously

For large datasets, asynchronous fetching can help improve performance by preventing the main thread from being blocked during long-running fetches. Here’s an example of how to perform an asynchronous fetch:



func fetchTasksAsync(context: NSManagedObjectContext) {
    let fetchRequest: NSFetchRequest = Task.fetchRequest()

    let asyncFetchRequest = NSAsynchronousFetchRequest(fetchRequest: fetchRequest) { result in
        guard let tasks = result.finalResult else { return }
        DispatchQueue.main.async {
            // Update UI with the fetched tasks
        }
    }

    do {
        try context.execute(asyncFetchRequest)
    } catch {
        print("Failed to perform asynchronous fetch: \(error.localizedDescription)")
    }
}

In this example, we use an NSAsynchronousFetchRequest to fetch tasks in the background. This allows the UI to remain responsive while the data is being fetched, improving performance in scenarios where large datasets are involved.


7. Profiling Core Data Performance with Instruments

Apple’s Instruments tool provides a way to profile and diagnose performance issues in your app. Specifically, the “Core Data” template in Instruments allows you to analyze Core Data queries, track memory usage, and detect performance bottlenecks. You can use this tool to optimize slow fetches, reduce memory consumption, and eliminate unnecessary database queries.

To use Instruments for profiling Core Data:

  • Open Xcode, then go to Product > Profile.
  • Select the “Core Data” template in Instruments.
  • Run your app and observe the Core Data query execution and memory usage.

Conclusion

Optimizing Core Data performance in SwiftUI requires a combination of techniques, including asynchronous operations, batch updates, reducing memory usage with faults, and limiting the data fetched. By following these strategies, you can ensure your app remains fast and responsive even as your dataset grows. In the next and final article, we’ll explore Core Data migration and versioning, ensuring smooth data migrations when your app’s data model changes.


In this article, we covered various techniques for optimizing Core Data performance in SwiftUI, including using background contexts, limiting fetches, prefetching data, and performing batch updates and deletions. These strategies help keep your app performant even with large datasets. In the next article, we’ll focus on handling Core Data migrations and versioning in SwiftUI.

instructor

Exodai INSTRUCTOR!

Johan t'Sas

Owner and Swift developer!