CloudKit sin servidor: sync offline-first en producción#
Cuando diseñaba la sincronización para MeteoHealth — una app que registra métricas de salud del usuario en iPhone, iPad y Apple Watch —, la primera pregunta no fue "qué framework", sino "¿de verdad necesito un servidor?". Datos personales de salud, tres plataformas, el requisito de funcionar sin red en un avión o en el sótano de un hospital, y ninguna gana de mantener un backend, una base de datos y DevOps para una sola app.
La respuesta a la que llegué tras varios meses en producción: no necesitas servidor si diseñas correctamente la sincronización offline-first sobre CloudKit. Este artículo no repite la documentación de Apple: es la experiencia concreta — qué funcionó, qué se rompió y qué haría distinto sabiendo lo que sé ahora.
Por qué elegí entre CloudKit, Firebase y Supabase#
Al principio parecía una decisión estándar de BaaS. Firebase es maduro, multiplataforma, con una comunidad enorme. Supabase es abierto, con Postgres por debajo y buena experiencia de desarrollo. CloudKit solo cubre el ecosistema Apple, pero viene integrado en el sistema operativo.
Tres factores específicos de los datos de salud inclinaron la balanza:
- Los datos viven físicamente en el iCloud personal del usuario, no en servidores que yo tenga que administrar, parchear y proteger. Eso no es solo comodidad: es menos superficie de ataque y menos responsabilidad al almacenar datos sensibles.
- La autenticación ya está resuelta. El usuario ya tiene un Apple ID; no tengo que construir registro, recuperación de contraseña y todo lo que eso implica para datos de salud.
- Cero infraestructura. Ningún servidor que pueda caerse a las 3 de la madrugada. Ninguna factura de hosting que crezca junto con la base de usuarios.
La contrapartida es el vendor lock-in con Apple y un techo más bajo de personalización: las transacciones complejas entre registros y las consultas arbitrarias del lado del servidor son más difíciles en CloudKit que en Postgres. Para una app que vive por completo dentro del ecosistema Apple, esa compensación valió la pena.
| Criterio | CloudKit | Firebase | Supabase |
|---|---|---|---|
| Servidor propio | No necesario | No necesario (gestionado) | Requiere self-host o Supabase Cloud |
| Almacenamiento | Base de datos privada del iCloud del usuario | Servidores de Google | Postgres (gestionado/self-host) |
| Multiplataforma | Solo ecosistema Apple | iOS/Android/Web | iOS/Android/Web |
| Autenticación | Apple ID listo para usar | Firebase Auth (requiere configuración) | Supabase Auth (requiere configuración) |
| Offline-first | Integrado en Core Data vía NSPersistentCloudKitContainer | Caché offline de Firestore | Requiere implementación manual |
| Coste al escalar | Cuotas gratuitas por usuario de Apple | Crece con el gasto en consultas de Firestore | Crece con la instancia de Postgres |
Arquitectura: Core Data sobre NSPersistentCloudKitContainer#
La primera versión de la sincronización en MeteoHealth está construida sobre NSPersistentCloudKitContainer — el camino más rápido para lograr comportamiento offline-first, porque Core Data ya funciona localmente sin red, y NSPersistentCloudKitContainer añade el espejado hacia CloudKit por debajo.
import CoreData
import CloudKit
final class PersistenceController {
static let shared = PersistenceController()
let container: NSPersistentCloudKitContainer
init(inMemory: Bool = false) {
container = NSPersistentCloudKitContainer(name: "MeteoHealthModel")
guard let description = container.persistentStoreDescriptions.first else {
fatalError("No persistent store description found")
}
// Required for CloudKit mirroring: history tracking + remote change notifications
description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
description.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
description.cloudKitContainerOptions = NSPersistentCloudKitContainerOptions(
containerIdentifier: "iCloud.pro.dodecaidr.meteohealth"
)
container.loadPersistentStores { _, error in
if let error = error as NSError? {
fatalError("Unresolved error loading store: \(error), \(error.userInfo)")
}
}
container.viewContext.automaticallyMergesChangesFromParent = true
// Field-level last-writer-wins. Good enough for most entities,
// but not for health metrics where "who wrote last" isn't "who is right".
container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
}
}Un matiz que no es obvio leyendo la documentación: los modelos de Core Data compatibles con CloudKit tienen restricciones estrictas: todos los atributos deben ser opcionales o tener un valor por defecto, no se admiten unique constraints, y las relaciones deben ser opcionales. Eso me obligó a rediseñar el modelo de datos antes de escribir una sola línea de código de sincronización: parte de los invariantes de negocio que antes se verificaban a nivel de esquema se movieron a validación a nivel de aplicación.
Base privada y zonas: cómo sincroniza entre iPhone, iPad y Apple Watch#
CloudKit divide los datos en tres tipos de base: pública, privada y compartida. Para datos personales de salud, solo encaja la base privada (private database) — vive físicamente en el almacenamiento iCloud propio del usuario, no es accesible para otros usuarios y nunca pasa por infraestructura que yo controle.
Dentro de la base privada, los datos se agrupan en zonas (CKRecordZone). Por defecto todo cae en defaultZone, pero para la sincronización offline-first las zonas personalizadas dan una ventaja crítica: operaciones batch atómicas de guardado y borrado dentro de una misma zona, además de tokens de cambio independientes — se puede sincronizar un grupo de registros relacionados (por ejemplo, todas las métricas de una visita al médico) como una unidad, sin riesgo de dejar un estado parcialmente aplicado en otro dispositivo.
En la práctica, para MeteoHealth esto significó una única zona HealthMetrics compartida entre las tres plataformas — iPhone, iPad y Apple Watch leen y escriben en la misma zona de la misma base privada de la misma cuenta de iCloud. No hace falta ningún servidor aparte para "sincronizar entre dispositivos": CloudKit es el transporte.
Conflictos en producción: qué significa realmente serverRecordChanged#
Aquí empieza la ingeniería de verdad, no el tutorial. NSPersistentCloudKitContainer resuelve conflictos automáticamente mediante NSMergePolicy — pero a nivel de campo, no de registro, y sin acceso a la versión "ancestral". Para la mayoría de los campos está bien: gana el campo modificado más tarde. Pero para las mediciones de salud, "quién escribió último" y "quién tiene razón" son preguntas distintas: si el usuario introdujo un valor en el iPhone a las 9:00, y una sincronización retrasada entregó a las 9:05 una medición automática del Apple Watch de las 8:45, un last-writer-wins ingenuo sobrescribe el valor anterior — pero no menos válido.
Aquí NSPersistentCloudKitContainer resultó demasiado de alto nivel: no da acceso a las tres versiones del registro detrás de serverRecordChanged — local, de servidor y ancestral. Para las entidades críticas, bajé al nivel de CKRecord sin procesar mediante CKSyncEngine.
CKSyncEngine: cuándo bajar de nivel respecto a Core Data#
CKSyncEngine, presentado en la WWDC23 para iOS 17+, es una API de nivel más bajo pero declarativa: se encarga de la mayor parte del trabajo pesado (tokens de cambio, reintentos, agrupación de notificaciones push), pero te devuelve control total sobre qué se envía exactamente y cómo se resuelven los conflictos.
import CloudKit
final class HealthSyncEngine: NSObject, CKSyncEngineDelegate {
private var syncEngine: CKSyncEngine!
private let zoneID = CKRecordZone.ID(zoneName: "HealthMetrics")
func configure(savedState: CKSyncEngine.State.Serialization?) {
let configuration = CKSyncEngine.Configuration(
database: CKContainer(identifier: "iCloud.pro.dodecaidr.meteohealth").privateCloudDatabase,
stateSerialization: savedState,
delegate: self
)
syncEngine = CKSyncEngine(configuration)
}
func handleEvent(_ event: CKSyncEngine.Event, syncEngine: CKSyncEngine) {
switch event {
case .stateUpdate(let update):
persistState(update.stateSerialization)
case .fetchedRecordZoneChanges(let event):
event.modifications.forEach { applyToLocalStore($0.record) }
event.deletions.forEach { deleteFromLocalStore(recordID: $0.recordID) }
case .sentRecordZoneChanges(let event):
// Records CloudKit rejected because the server version moved on
// while we were offline.
for failure in event.failedRecordSaves {
guard failure.error.code == .serverRecordChanged,
let serverRecord = failure.error.serverRecord else { continue }
let resolved = mergeHealthRecord(local: failure.record, server: serverRecord)
syncEngine.state.add(pendingRecordZoneChanges: [.saveRecord(resolved.recordID)])
stageForNextSave(resolved)
}
default:
break
}
}
func nextRecordZoneChangeBatch(
_ context: CKSyncEngine.SendChangesContext,
syncEngine: CKSyncEngine
) async -> CKSyncEngine.RecordZoneChangeBatch? {
await CKSyncEngine.RecordZoneChangeBatch(
pendingChanges: context.options.scope.pendingRecordZoneChanges
) { recordID in
recordToSave(for: recordID)
}
}
}La parte clave es failedRecordSaves con el código de error .serverRecordChanged. A diferencia de NSPersistentCloudKitContainer, CKSyncEngine me entrega el registro real del servidor — con un recordChangeTag actualizado, sin el cual reenviar sería rechazado de nuevo. Construyo un registro nuevo sobre la versión del servidor y aplico mi propia lógica de negocio:
/// serverRecordChanged gives us the current server record with a valid
/// change tag. Re-sending the local record's stale tag would just be
/// rejected again — we must copy the server record and reapply our fields.
private func mergeHealthRecord(local: CKRecord, server: CKRecord) -> CKRecord {
let merged = server.copy() as! CKRecord
let localRecordedAt = local["recordedAt"] as? Date ?? .distantPast
let serverRecordedAt = server["recordedAt"] as? Date ?? .distantPast
// The freshest measurement wins — not whoever reached the server first.
if localRecordedAt > serverRecordedAt {
merged["value"] = local["value"]
merged["recordedAt"] = local["recordedAt"]
merged["source"] = local["source"]
}
return merged
}Así el conflicto se resuelve según el significado de los datos (el momento real de la medición), no según el azar de la latencia de red. Para el resto de entidades menos críticas de MeteoHealth mantuve NSPersistentCloudKitContainer — no tiene sentido reescribir toda la pila hacia CKSyncEngine donde el last-writer-wins a nivel de propiedad nunca causa problemas.
Push y suscripciones: cómo se enteran los dispositivos al instante#
Sin notificaciones push, la sincronización entre iPhone, iPad y Apple Watch solo funcionaría por temporizador o al abrir la app — no lo que un usuario espera de "una sola cuenta de iCloud". CKDatabaseSubscription vigila toda la base privada (incluidas las zonas nuevas) y envía un push silencioso con cada cambio:
func setupDatabaseSubscription(database: CKDatabase) async throws {
let subscription = CKDatabaseSubscription(subscriptionID: "meteohealth-private-db-changes")
let notificationInfo = CKSubscription.NotificationInfo()
notificationInfo.shouldSendContentAvailable = true // silent push, no banner
subscription.notificationInfo = notificationInfo
try await database.save(subscription)
}Al recibir ese push, la app no muestra nada al usuario — simplemente dispara la descarga de los cambios pendientes mediante CKSyncEngine:
func application(
_ application: UIApplication,
didReceiveRemoteNotification userInfo: [AnyHashable: Any],
fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void
) {
guard let notification = CKNotification(fromRemoteNotificationDictionary: userInfo),
notification.subscriptionID == "meteohealth-private-db-changes" else {
completionHandler(.noData)
return
}
Task {
try? await syncEngine.fetchChanges()
completionHandler(.newData)
}
}Con NSPersistentCloudKitContainer no hace falta configurar esta suscripción a mano — ya viene resuelta dentro del framework. La configuración manual solo importa cuando se trabaja directamente con CKSyncEngine.
Trampas, límites y checklist para producción#
Cosas que ningún tutorial menciona, pero que me costaron horas reales de depuración:
- El CloudKit Dashboard no es lugar para migraciones en caliente. Cambiar el esquema de un registro en producción sin desplegar antes al entorno Development y promoverlo garantiza incompatibilidad en los dispositivos que aún no se actualizaron.
- Los rate limits fallan en silencio. CloudKit no lanza un error vistoso al superar la cuota de peticiones — a veces las operaciones simplemente se posponen y se reintentan después. Si la lógica de la app espera una respuesta instantánea, esto parece una sincronización "colgada".
- El Simulador miente sobre el modo offline. Probar escenarios reales de corte de red y los conflictos de merge resultantes hay que hacerlo en dispositivos físicos con el Wi-Fi realmente apagado, no con los interruptores de depuración del Simulador.
- La cuenta de iCloud puede no estar iniciada, o estar en modo restringido (Family Sharing, Apple ID gestionado). La app debe degradar con elegancia — funcionar localmente y mostrar un estado claro, no fallar.
- La primera sincronización completa tras una reinstalación es el escenario más pesado en tráfico y tiempo; lo pruebo deliberadamente aparte, sin confiar en escenarios incrementales donde todo funciona sin fricción.
Checklist antes de lanzar una función con sincronización CloudKit:
- El esquema de Core Data es compatible con las restricciones de CloudKit (atributos opcionales, sin unique constraints)
- Las entidades críticas tienen una estrategia explícita de resolución de conflictos, no el property trump por defecto
-
CKDatabaseSubscriptionestá configurada y probada (paraCKSyncEnginemanual) - Probado: dos dispositivos offline al mismo tiempo hacen cambios en conflicto
- Probado: la cuenta no está iniciada en iCloud
- Probada la primera sincronización completa desde cero
- Los logs no tratan los reintentos silenciosos como errores fatales
Conclusión. CloudKit no es la respuesta universal a "cómo sincronizo datos", pero para una app que vive por completo dentro del ecosistema Apple y maneja datos personales del usuario, es un reemplazo justificado de un backend propio: menos infraestructura, autenticación integrada y datos que permanecen físicamente en el iCloud del usuario, no en mis servidores. El precio es entender la diferencia entre el NSPersistentCloudKitContainer cómodo pero tosco y el CKSyncEngine preciso pero más verboso, y elegir la herramienta según cada entidad, no para todo el proyecto de una vez.



