Nested Core Data Contexts Can Block the UI

There is probably an infinite number of ways to design a Core Data stack. One mistake that I’ve experienced in the past is nesting contexts like this:

The idea is the child contexts will be used for saving in the background, and the main context (parent) is the source of truth for all the children. The main context is also used for reading the data and presenting it in the UI.

This is generally okay. And it works up to a point. An unfortunate requirement is that the child contexts have to propagate their changes to the main context to persist the data. And then, the main context has to be saved, so the changes are persistent to the underlying NSPersistentStore.

And therein lies the problem. As defined in the Apple documentation, the main context uses the main queue. This means that using the main context for saving or writing can block the main queue, where the UI is updated. And this may lead to the app looking like it froze while processing large amounts of data.

I made an example app to prove that this happens. The app inserts 25,000 random strings in the background child context and then permanently saves it by calling save() on the main context. Here is a video of the performance test:

You’ll notice around the 10-second mark that the Seconds elapsed label stopped updating. And at the end, it quickly updates from 0.8 to 4.4 seconds. This is ample evidence that the main queue did not get a chance to update the label until it finished persisting the data.

We can also observe this using the Time Profiler. During the saving process, the main queue usage increases dramatically.

This is how this nested context setup might look like in code (NestedCoreDataStack.swift):

final class NestedCoreDataStack: CoreDataStack {
    private let persistentContainer = try! startPersistentContainer()

    private var parentContext: NSManagedObjectContext {
        persistentContainer.viewContext
    }

    private(set) lazy var writerContext: NSManagedObjectContext = {
        let context = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
        context.parent = self.parentContext
        return context
    }()

    func save(_ completion: @escaping () -> ()) {
        writerContext.perform {
            self.writerContext.saveIfNeeded()

            self.parentContext.perform {
                self.parentContext.saveIfNeeded()
                completion()
            }
        }
    }
}

An Alternative

To avoid blocking the main queue, we can have separate, same-level contexts instead. We can still have two types:

  • A background context for writing data.
  • The main queue context for fetching and displaying data in the UI.

But instead of nesting, we can directly connect them to the NSPersistentContainer (technically, NSPersistentStoreCoordinator). And taking advantage of NSPersistentStoreContainer.newBackgroundContext(), both contexts are automatically kept in sync whenever the data is changed. This is the recommended setup from the objc.io Core Data book.

Here is a video of the behavior when we use the same performance test but with this setup:

Unlike the nested contexts setup, the Seconds elapsed label gets updated without hiccups. What this means is that the main queue is never interrupted whenever the data is being saved.

Using the Time Profiler, you’ll notice no significant increase in the main queue usage during the saving process. There is a slight increase at the end. Though I suspect that is caused by the main context merging the changes from the background context.

Here is how this stack might look like in code (ConcurrentCoreDataStack.swift):

final class ConcurrentCoreDataStack: CoreDataStack {
    private let persistentContainer = try! startPersistentContainer()

    var readerContext: NSManagedObjectContext {
        persistentContainer.viewContext
    }

    private(set) lazy var writerContext: NSManagedObjectContext = {
        let context = persistentContainer.newBackgroundContext()
        context.name = "WriterContext"
        return context
    }()

    func save(_ completion: @escaping () -> ()) {
        writerContext.perform {
            self.writerContext.saveIfNeeded()
            completion()
        }
    }
}

I find this more straightforward than the nested setup. And I think this is more than enough for most apps to get started with.

You can play around with the nested and concurrent setups yourself by downloading the project here: CoreDataStack. And the performance test is in PerformanceTestViewController.swift.