This post was inspired by a question I came across on Reddit. The question was simple but interesting:
Is there a way to show the sync status between SwiftData and iCloud in a SwiftUI app?
At first glance this seems like something Apple would provide out of the box. After all, SwiftData makes enabling iCloud sync incredibly easy. But once you start looking for an API that tells you when syncing starts, finishes, or fails, you quickly realize something surprising.
There is no such API.
SwiftData does a great job syncing data with iCloud behind the scenes, but it does not expose any direct way to observe the sync progress.
Fortunately, the story does not end there. SwiftData is built on top of Core Data with CloudKit integration, and Core Data exposes a set of notifications that tell us when sync events occur. By listening to those notifications we can build a simple but useful sync monitor view.
Let’s build one.
You can watch this small video of the end result.
For this project I created a very small SwiftData application. The goal here is not to build a full Todo app but simply to have something that can create data and trigger CloudKit sync.
The app allows the user to add Todo items and displays them in a list.
Below is the TodoItem model and the ContentView.
@Model
class TodoItem {
var name: String = ""
init(name: String) {
self.name = name
}
}
struct ContentView: View {
@Environment(\.modelContext) private var modelContext
@Query private var todoItems: [TodoItem]
@State private var name: String = ""
var body: some View {
NavigationStack {
List {
Section("Add Todo") {
TextField("Name", text: $name)
Button("Save") {
let todoItem = TodoItem(name: name)
modelContext.insert(todoItem)
try? modelContext.save()
name = ""
}
}
Section("Todo Items") {
ForEach(todoItems) { todoItem in
HStack {
Text(todoItem.name)
}
}
}
}
.navigationTitle("Todo Items")
}
}
}
The project is already configured to work with iCloud. This includes:
• CloudKit entitlements • iCloud container configuration • background mode for remote notifications
Once everything is configured, syncing works automatically.
If you run the app on a physical device and add a Todo item, the data is uploaded to iCloud. You can verify this by opening the CloudKit Console and inspecting your records.
You can even edit the record directly in the CloudKit Console and the change will appear in your app almost immediately without restarting the app.
This is one of the really nice aspects of SwiftData. The iCloud integration is extremely simple to set up.
However, there is one problem.
The user has no idea when syncing is happening.
If data is uploading, downloading, or failing to sync, the UI provides no indication.
To solve that problem we will build a small utility called CloudSyncMonitor.
The purpose of CloudSyncMonitor is simple. It listens for CloudKit sync events and converts them into a small set of states that our UI can display.
First we define the different statuses our app can report.
@Observable
final class CloudSyncMonitor {
enum Status: Equatable {
case idle
case syncing(String) // "Uploading" / "Downloading" / "Setting up"
case success
case failed(String)
}
}
These states represent the current sync activity.
idle
syncing("Uploading to iCloud")
syncing("Downloading from iCloud")
success
failed("Network error")
Once we have these states, the next step is to start listening for CloudKit events.
The start function begins monitoring CloudKit synchronization.
SwiftData does not provide any API that tells us when syncing begins or ends. However, the underlying Core Data stack sends notifications whenever a CloudKit sync event changes.
We can listen to those notifications using NotificationCenter.
func start() {
observer = NotificationCenter.default.addObserver(
forName: NSPersistentCloudKitContainer.eventChangedNotification,
object: nil,
queue: .main
) { [weak self] notification in
guard
let event = notification.userInfo?[NSPersistentCloudKitContainer.eventNotificationUserInfoKey]
as? NSPersistentCloudKitContainer.Event
else { return }
let label: String
switch event.type {
case .setup:
label = "Setting up iCloud sync"
case .import:
label = "Downloading from iCloud"
case .export:
label = "Uploading to iCloud"
@unknown default:
label = "Syncing"
}
// Event started
if event.endDate == nil {
self?.status = .syncing(label)
return
}
// Event finished with error
if let error = event.error {
self?.status = .failed(error.localizedDescription)
return
}
// Event finished successfully
if event.succeeded {
self?.status = .success
} else {
self?.status = .idle
}
}
}
The start function is responsible for starting the sync monitor. In other words, it tells our CloudSyncMonitor to begin listening for events emitted by the CloudKit sync engine.
SwiftData itself does not provide an API that tells us when syncing begins or ends. However, under the hood SwiftData relies on Core Data with CloudKit integration, and Core Data exposes notifications whenever a sync event changes. The start function simply registers a listener for those notifications.
The first part of the function registers an observer with NotificationCenter.
observer = NotificationCenter.default.addObserver(
forName: NSPersistentCloudKitContainer.eventChangedNotification,
object: nil,
queue: .main
)
We are telling the system:
Whenever the CloudKit sync engine reports a change in sync activity, notify this object.
The notification we are listening for is:
NSPersistentCloudKitContainer.eventChangedNotification
This notification is triggered whenever CloudKit begins a sync operation, progresses through it, or completes it.
The queue: .main parameter ensures that the closure runs on the main thread, which is important because the monitor updates UI state.
When the notification fires, it contains additional information inside the userInfo dictionary. The most important item is the CloudKit event object.
guard
let event = notification.userInfo?[NSPersistentCloudKitContainer.eventNotificationUserInfoKey]
as? NSPersistentCloudKitContainer.Event
else { return }
If the event cannot be extracted, we simply return.
The event object describes what the sync engine is doing. It contains details such as:
• the type of operation • when it started • when it finished • whether it succeeded • whether an error occurred
The next step is determining what type of sync activity is happening.
switch event.type {
case .setup:
label = "Setting up iCloud sync"
case .import:
label = "Downloading from iCloud"
case .export:
label = "Uploading to iCloud"
@unknown default:
label = "Syncing"
}
CloudKit sync events typically fall into three categories.
setup
Occurs when the app initializes its CloudKit environment.
import
Occurs when data is downloaded from iCloud to the device.
export
Occurs when local data is uploaded from the device to iCloud.
We convert these event types into human readable labels so they can be displayed in the UI.
Next we check whether the event has finished.
if event.endDate == nil {
self?.status = .syncing(label)
return
}
If endDate is nil, the sync operation is still in progress.
At this point we update the monitor’s status to:
.syncing("Uploading to iCloud")
or
.syncing("Downloading from iCloud")
Because the class is marked with @Observable, this change will automatically update any SwiftUI view observing the monitor.
If the event has completed, the next thing we check is whether it finished with an error.
if let error = event.error {
self?.status = .failed(error.localizedDescription)
return
}
If CloudKit encountered a problem, the event will contain an error object. In that case we update the status to:
.failed(error message)
This allows the UI to display a message indicating that the sync operation failed.
Finally, we check whether the event completed successfully.
if event.succeeded {
self?.status = .success
} else {
self?.status = .idle
}
If event.succeeded is true, we update the status to .success. This indicates that the sync operation completed successfully.
If not, we simply fall back to .idle.
The start function effectively converts low level CloudKit events into a simple state machine.
The flow looks like this:
CloudKit event occurs
↓
NotificationCenter fires notification
↓
CloudSyncMonitor receives event
↓
Event is analyzed
↓
Status is updated
↓
SwiftUI view refreshes automatically
This is what allows our UI to show a message such as:
Uploading to iCloud
Downloading from iCloud
Sync completed
Sync failed
Even though SwiftData itself does not expose a direct API for sync progress, this approach gives us a reasonable approximation of the sync state using the underlying CloudKit infrastructure.
Now that we have a monitor tracking sync activity, the next step is displaying that information to the user.
We can create a simple SwiftUI view called SyncStatusView.
struct SyncStatusView: View {
let monitor: CloudSyncMonitor
var body: some View {
HStack(spacing: 8) {
icon
Text(title)
.font(.subheadline)
.fontWeight(.medium)
}
.foregroundStyle(color)
.padding(.vertical, 8)
.padding(.horizontal, 12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(backgroundColor)
.clipShape(RoundedRectangle(cornerRadius: 10))
.animation(.easeInOut, value: monitor.status)
}
// other code here
}
This view observes the monitor’s status and updates automatically whenever the sync state changes.
Finally we can integrate the view into our main screen.
struct ContentView: View {
@Environment(\.modelContext) private var modelContext
@Query private var todoItems: [TodoItem]
@State private var name: String = ""
@State private var isCompleted: Bool = false
@State private var syncMonitor = CloudSyncMonitor()
var body: some View {
NavigationStack {
List {
Section {
SyncStatusView(monitor: syncMonitor)
}
Section("Add Todo") {
TextField("Name", text: $name)
Button("Save") {
let todoItem = TodoItem(name: name)
modelContext.insert(todoItem)
try? modelContext.save()
name = ""
}
}
Section("Todo Items") {
ForEach(todoItems) { todoItem in
HStack {
Text(todoItem.name)
}
}
}
}
.navigationTitle("Todo Items")
}
.onAppear {
syncMonitor.start()
}
}
}
When the view appears, we call:
syncMonitor.start()
This activates the sync monitor, and the UI will automatically update whenever CloudKit begins or finishes a sync operation.
The technique we used works well for exposing basic sync activity, but it is important to understand that it is not a perfect solution. The APIs we are relying on come from the underlying Core Data CloudKit integration, and they were never designed to be a polished, user facing sync status API.
In other words, we are tapping into internal signals that the sync engine emits. That gives us useful visibility, but there are a few limitations worth keeping in mind.
One of the biggest limitations is that we cannot determine how much of the sync operation has completed.
The events we receive only tell us when a sync operation starts and when it finishes. They do not provide incremental progress updates.
This means we cannot build something like a progress bar showing:
Uploading 20%
Uploading 60%
Uploading 90%
Instead, we can only display high level states such as:
For most applications this is still helpful, but it is not a precise representation of sync progress.
Another thing you might notice is that sync events do not always occur immediately after a user performs an action.
When a user inserts or updates a record, SwiftData first writes the change to the local store. The upload to iCloud may happen a few seconds later because the sync engine often batches operations together.
Because of this, you might see the syncing indicator appear slightly after the user performs an action.
CloudKit synchronization often consists of several operations happening in sequence. During a single sync cycle you might observe something like this:
Uploading to iCloud
Sync completed
Downloading from iCloud
Sync completed
This happens because CloudKit may perform both export and import operations during the same cycle. For example, it may upload your local changes and then immediately pull down updates from other devices.
For this reason, the status displayed by our monitor should be treated as informational rather than perfectly precise.
CloudKit also performs internal operations that do not always surface as clear events.
Examples include background scheduling, database maintenance, and conflict resolution. These activities may happen without producing a clean status message that we can display to the user.
Finally, this monitor assumes that the device is signed into iCloud and that CloudKit syncing is enabled for the app.
If the user is not logged into iCloud, or if iCloud is disabled for the app, the monitor will not provide much useful information. Similarly, network interruptions can delay synchronization events, which may make the UI appear idle even though the system intends to sync later.
Despite these limitations, this approach still provides valuable visibility into the SwiftData sync process. SwiftData makes syncing incredibly easy, but it also hides most of what is happening behind the scenes. By listening to the underlying CloudKit events, we can at least surface some meaningful signals to the user and gain better insight into what the sync engine is doing.
You can watch a small demo video here.
You can download the complete source code using the following Gist link: https://gist.github.com/azamsharpschool/f66500e4d6df195802ae9f422ef157bc
SwiftData makes enabling iCloud sync surprisingly simple. With just a few configuration steps, your data starts flowing between devices almost automatically. The downside, however, is that the framework keeps most of the syncing process hidden from the developer and the user. When data is uploading, downloading, or even failing to sync, the UI remains completely silent.
In this article we built a small utility called CloudSyncMonitor that listens to the underlying CloudKit events exposed by Core Data. Even though SwiftData does not directly expose sync progress APIs, these notifications allow us to build a reasonable approximation of the sync state. By translating those events into a few simple states such as syncing, success, and failure, we can provide meaningful feedback to users and make debugging CloudKit issues much easier.
The SyncStatusView we created is intentionally simple, but it demonstrates the core idea. Once you have a monitor observing CloudKit events, you can design the UI in any way that fits your application. Some apps may display a small banner, others may show an icon in the navigation bar, and some may simply log sync activity for debugging purposes.
The key takeaway is that SwiftData iCloud sync does not have to be a complete black box. By tapping into the underlying CloudKit notifications, we can gain visibility into what the sync engine is doing and build better user experiences around it.
As SwiftData continues to evolve, Apple may eventually expose higher level APIs for observing sync activity. Until then, this approach provides a practical way to monitor and surface sync behavior in your SwiftData applications.