Core Data on macOS: Background Contexts and Persistent History
Build reliable Core Data background pipelines with queue-confined contexts, object-ID handoff, persistent history tokens, merges, and recovery.
Core Data is an object graph and persistence framework, not a thread-safe collection of model objects. A reliable macOS application treats each NSManagedObjectContext as an isolated workspace with a queue contract. The UI context serves view-facing objects; background contexts import or transform data; persistent history provides a durable record of store transactions that consumers can process after a notification, restart, or batch operation.
The most important rule is simple: a managed object belongs to the context and queue that fetched or inserted it. Passing that object to another queue because it “looks like a model” can violate Core Data’s confinement rules. Pass an object ID or an immutable value snapshot, then resolve or reconstruct the value inside the receiving context.
Contexts are isolated object spaces
An NSPersistentContainer provides a viewContext associated with the main queue, as well as APIs such as newBackgroundContext() and performBackgroundTask(_:) for private-queue contexts. Use the main context for UI-facing fetches and changes that a view observes. Move imports, expensive transformations, and bulk processing to a private context.
Every operation on a private context belongs inside perform, performAndWait, or the corresponding async context API. The context’s queue is private to it; do not assume that a GCD queue you created is interchangeable with that queue. Keep a background transaction cohesive: fetch or create objects, apply a bounded batch, and save or roll back before leaving the context block.
container.performBackgroundTask { context in
do {
for item in incomingBatch {
let request = NSFetchRequest<Entry>(entityName: "Entry")
request.fetchLimit = 1
request.predicate = NSPredicate(format: "externalID == %@", item.id)
let entry = try context.fetch(request).first ?? Entry(context: context)
entry.externalID = item.id
entry.title = item.title
}
if context.hasChanges {
try context.save()
}
} catch {
context.rollback()
reportImportFailure(error)
}
}
Entry and incomingBatch are application types; the snippet assumes externalID is a stable field and the model contains the matching entity. In a large import, avoid one fetch per row: prefetch existing keys or use a batch strategy, process bounded chunks, and measure memory and store I/O. A uniqueness constraint can protect identity, but it does not define the correct merge policy by itself. Decide whether an incoming value, a local edit, or a conflict-resolution flow wins, and test duplicate input and retry behavior.
Cross the boundary with identifiers or values
If one context needs to refer to an object owned by another, pass its NSManagedObjectID; resolve that ID using the destination context’s existingObject(with:) or related lookup API inside the destination context’s queue. New inserted objects may initially have temporary IDs. Save first or request permanent IDs before handing an ID to another context. An immutable Sendable DTO is often better when the consumer only needs a few fields and should not fetch the full object graph.
Object IDs identify records, not snapshots. The receiving context can have stale registered values or the object can have been deleted. Handle resolution errors and deletion explicitly. Do not retain a managed object after its context is reset, and do not use an object ID as a substitute for a domain-level identifier when data must survive store replacement or migration.
Saves, merges, and stale views
A context save writes its pending changes through its parent context or persistent store coordinator. In a parent-child stack, saving a child only pushes changes to its parent; the root must also save before the changes reach the persistent store. Container-created background contexts usually connect to the persistent store coordinator, but code should still define and test its context topology.
Another context does not automatically become a live mirror of every change. Configure a deliberate merge policy and merge path. For ordinary context saves, options include observing context-save notifications or enabling the view context’s automatic merge behavior where appropriate. A merge policy resolves conflicts; it is not a product-level decision about which user’s edit should win. UI refreshes should refetch or merge only the data the screen needs, and code must not assume a fault reads the newest value regardless of context staleness settings.
Batch insert, update, and delete operations are efficient because they can operate at the persistent-store level without materializing every managed object. That efficiency changes the notification story: a batch operation does not provide the same per-object context-save notifications as changing objects in a context. If another context or process must learn about those store-level changes, persistent history is the durable mechanism to inspect.
Enable and consume persistent history
Persistent history tracking is opt-in for a store. Set the option on each relevant persistent store description before calling loadPersistentStores. Use a durable store when the consumer must recover across launches; an in-memory store cannot provide restart recovery because the store itself is ephemeral. If the application also wants notifications after another process writes to the store, enable the remote-change notification option. The notification is a wake-up signal, not a complete change payload; fetch history to find the transactions and changes.
let container = NSPersistentContainer(name: "Library")
guard let description = container.persistentStoreDescriptions.first else {
fatalError("The container has no persistent store description")
}
description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
description.setOption(
true as NSNumber,
forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey
)
container.loadPersistentStores { _, error in
if let error {
reportStoreLoadFailure(error)
}
}
Install the remote-change observer only after the store is configured, and move notification handling onto a known executor. Apple documents that the notification can be posted on a private thread. Coalesce bursts: multiple notifications can arrive while one history drain is already running, and a notification handler should not block the UI.
Persist one history token per store and logical consumer. On notification or launch, execute NSPersistentHistoryChangeRequest.fetchHistory(after:) on a background context, optionally restrict the request to the changed store, and inspect the returned NSPersistentHistoryTransaction values. Filter by entity, transaction author, or affected property when a consumer only needs a subset. A transaction can include context saves or batch operations; do not assume every transaction came from the same process or screen.
The processing order is a recovery protocol. Read the saved token, fetch subsequent transactions, perform the required merge or downstream update, and only then persist the newest token. If the process crashes before the token advances, the same transactions can be seen again. Make downstream work idempotent. Advancing the token before the work succeeds can permanently skip changes. Where multiple consumers have different obligations, they need independent tokens rather than a shared cursor that advances when only one consumer is done.
@preconcurrency import CoreData
import Foundation
func consumeHistory(
in container: NSPersistentContainer,
storeURL: URL,
after token: NSPersistentHistoryToken?
) async throws {
let context = container.newBackgroundContext()
let transactions = try await context.perform {
guard let coordinator = context.persistentStoreCoordinator,
let store = coordinator.persistentStores.first(where: { $0.url == storeURL }) else {
throw HistoryError.storeNotLoaded
}
let request = NSPersistentHistoryChangeRequest.fetchHistory(after: token)
request.affectedStores = [store]
guard let result = try context.execute(request) as? NSPersistentHistoryResult,
let transactions = result.result as? [NSPersistentHistoryTransaction] else {
throw HistoryError.unexpectedResult
}
return transactions
}
for transaction in transactions {
await container.viewContext.perform {
container.viewContext.mergeChanges(
fromContextDidSave: transaction.objectIDNotification()
)
}
}
if let newestToken = transactions.last?.token {
try persistHistoryToken(newestToken, for: storeURL)
}
}
enum HistoryError: Error {
case storeNotLoaded
case unexpectedResult
}
The caller supplies the store URL as a value so the background context resolves its own NSPersistentStore instead of capturing a store instance across queues. The token persistence helper must durably associate the token with the store identity and the consumer. In a real application, also serialize drains so two observers cannot race the same cursor, handle store replacement or token invalidation by rebuilding consumer state, and record processing errors before deciding whether to retry. If a consumer performs an external side effect, its idempotency key and commit point must be designed with that external system; Core Data does not make a remote API call atomic with a local store save.
History retention and observability
Persistent history grows until the application purges it. Do not purge merely because a file is large or because one consumer has caught up. A second process, an offline extension, or a CloudKit-backed container may still need older transactions. Define the retention boundary for every consumer, persist tokens safely, and purge only when the oldest retained transaction is no longer required. If the app uses NSPersistentCloudKitContainer, follow Apple’s CloudKit history guidance: the container itself consumes history, so purging transactions that it still needs can force additional synchronization work.
Record store identity, consumer identity, token age, transaction count, batch duration, merge duration, and the last successful checkpoint. Avoid logging personal model values. Alert on a history backlog that grows continuously, repeated token resets, import failures, and token checkpoints that stop advancing. A remote-change notification arriving is not proof that a view has merged the change; instrument each stage separately.
Production validation checklist
Test two contexts editing the same record under the chosen merge policy. Import duplicate input twice and prove the final store state is stable. Test a process interruption after a history fetch but before a token update, then verify replay is safe. Exercise a batch delete and confirm the view context and any extension learn about it through the chosen history path. Delete an object after another context has received its ID and verify stale resolution is handled. Test store migration or replacement with token state from the prior store.
Finally, use Core Data concurrency debugging in development, keep fetches and merges off the UI queue when they can grow, and measure batch sizes against realistic data. The durable architecture is a set of queue-confined contexts plus explicit identifier handoffs and consumer-specific checkpoints. Notifications improve responsiveness; persistent history and idempotent processing provide the recovery story.
Related:
- macOS File Coordination: NSFileCoordinator and NSFilePresenter Without Deadlocks
- File Provider on macOS: Domains, Placeholders, and System-Managed Sync
Sources: