Diseña una app de notas offline-first (piensa: un Notion/Evernote móvil)
Recorre una arquitectura donde la base de datos local es la fuente de verdad y la red es solo otro escritor más: sincroniza, no bloquees.
APRENDE HACIENDOPon a prueba tu intuición3 pasos
Diseña una app de notas offline-first (piensa: un Notion/Evernote móvil)
Usa los ejemplos y la explicación de la lección para comprobar tu idea.
Reflexión personal. No se envía a ningún servicio de IA.#Aclarando los requisitos
Antes de dibujar una sola caja, dejaría claro qué significa realmente "offline-first" para este producto, porque la frase se usa con mucha ligereza y eso cambia la arquitectura. Una app de notas al estilo Notion o Evernote necesita: lecturas y escrituras locales instantáneas (escribir nunca debe esperar a un viaje de red), notas organizadas en una jerarquía (cuadernos, páginas, bloques anidados), contenido enriquecido (texto, checklists, imágenes, quizás tablas embebidas), sincronización multi-dispositivo para el mismo usuario y —la parte difícil— la capacidad de que dos dispositivos editen la misma nota estando ambos offline, y luego reconciliar sin destruir en silencio el trabajo de uno de los dos lados. También preguntaría por la escala (¿es una herramienta personal o necesita cuadernos compartidos/colaborativos con otros usuarios?) y por las expectativas de consistencia: ¿es aceptable "eventualmente consistente, last-writer-wins está bien para el 95% de las ediciones, pero nunca podemos perder datos de forma definitiva"? Para esta respuesta asumiré: la edición de un solo usuario es el caso principal, los cuadernos compartidos son una función secundaria, y la pérdida de datos es inaceptable incluso bajo ediciones concurrentes en conflicto.
#La apuesta arquitectónica central
La decisión más importante es esta: la base de datos local es la fuente de verdad para la UI, siempre — no la red. Cada pantalla lee y escribe contra el almacén local, punto. La capa de red baja de categoría: pasa de ser "lo que posee los datos" a ser "un proceso en segundo plano que mueve filas hacia adentro y hacia afuera del almacén local." Esta inversión es lo que hace que la app se sienta instantánea (ningún spinner bloquea jamás una pulsación de tecla) y lo que hace que estar offline sea... el caso normal, no un modo especial que la app tiene que detectar y ramificar. En concreto, eso significa que la capa de UI se construye sobre una base de datos local reactiva — Room con Flow en Android, Core Data con NSFetchedResultsController/Combine en iOS, o un motor embebido multiplataforma como SQLite vía SQLDelight o Realm— y la UI observa consultas contra esa base de datos. Una pulsación de tecla escribe en la BD local dentro de una transacción, la consulta reactiva vuelve a emitir, la UI se re-renderiza. El motor de sincronización es simplemente otro cliente de esa misma base de datos: lee los cambios locales que debe enviar y escribe los cambios remotos que descarga, a través del mismo camino de escritura que usa la UI (para que la resolución de conflictos y los triggers de seguimiento de cambios se disparen de forma consistente sin importar quién escribe).
#Almacenamiento local y el registro de cambios
Cada nota se guarda como una fila (o, si quiero resolución de conflictos de grano fino, como un árbol de bloques —una fila por párrafo/ítem de checklist/encabezado—, que es exactamente cómo Notion modela su contenido en la realidad). Junto a las tablas de contenido, mantengo un registro de operaciones de solo-anexado: cada mutación local se graba como una operación pequeña y estructurada —{opId, entityId, field, value, timestamp, deviceId, vectorClock}— antes de, o de forma atómica con, la mutación misma. Este registro es la unidad de sincronización: en lugar de comparar documentos completos, el motor de sync envía un flujo de operaciones, lo cual es a la vez eficiente en ancho de banda y nos da la materia prima para resolver conflictos. Versionaría cada nota (o cada bloque, para mayor granularidad) con un vector clock (contadores lógicos por dispositivo) o, más pragmáticamente, un hybrid logical clock — una marca de tiempo monótona por dispositivo y comparable entre dispositivos sin necesitar sincronización NTP de grado exacto. El vector clock es lo que realmente permite detectar un conflicto (dos ediciones a la misma entidad donde ninguno de los relojes domina al otro) en lugar de simplemente adivinar según el reloj de pared, que se desincroniza y miente.
#Motor de sincronización y resolución de conflictos
El motor de sincronización corre como un worker en segundo plano (WorkManager en Android, un BGProcessingTask en iOS) con tres responsabilidades: push de las operaciones locales que el servidor no ha visto, pull de las operaciones remotas desde el último cursor, y reconciliación de operaciones superpuestas en un único resultado determinista. Para el push, el agrupamiento importa: no dispares una llamada de red por cada pulsación de tecla; aplica debounce a la cola de operaciones locales (por ejemplo, vacía la cola 1–2 segundos después de que el usuario deja de escribir, o inmediatamente cuando la app pasa a segundo plano) para que un párrafo de texto se convierta en una sola petición de red, no en doscientas. Para la resolución de conflictos, elegiría una estrategia proporcional al tipo de campo real: para campos escalares (el título de una nota, el estado marcado de un checkbox) last-writer-wins por hybrid logical clock es simple y suficientemente bueno — el costo de UX de perder ocasionalmente la edición "perdedora" en un único booleano es bajo. Para el cuerpo de la nota —prosa real— last-writer-wins es inaceptable, porque borra en silencio un párrafo que alguien escribió estando offline. Ahí modelaría el contenido como un CRDT (un CRDT de secuencia como RGA, o el enfoque basado en árboles que usan los editores colaborativos reales), que fusiona inserciones/eliminaciones concurrentes de forma determinista sin que una autoridad central resuelva nada — ambos dispositivos convergen al mismo resultado de forma independiente. Esto es genuinamente más complejo de implementar, así que lo delimitaría explícitamente: CRDTs para el texto del cuerpo y el árbol de bloques, LWW simple por campo para todo lo demás (metadata, etiquetas, estado de fijado).
#Manejando los casos límite
Algunos casos límite deciden si este diseño sobrevive al contacto con la realidad. Primero, la conectividad parcial —un dispositivo con conexión inestable que completa la sincronización a medias— se maneja haciendo el protocolo de push idempotente (cada operación lleva un UUID generado por el cliente, así que reintentarla tras una respuesta perdida es un no-op en el servidor) y nunca marcando las operaciones locales como "sincronizadas" hasta que el servidor las confirme explícitamente, no solo al completarse la petición. Segundo, los archivos multimedia grandes (imágenes, adjuntos) no deberían viajar en el mismo registro de operaciones que el texto; se suben por separado a almacenamiento de objetos, y la nota solo guarda una referencia y una ruta de caché local, así una subida de imagen lenta nunca bloquea la sincronización del texto. Tercero, los tombstones: borrar una nota offline tiene que propagarse como una operación de borrado, no como una ausencia silenciosa, o un dispositivo que se resincroniza desde un cursor antiguo la va a "resucitar." Finalmente, expondría el estado de sincronización de forma honesta en la UI —un indicador sutil por nota (sincronizada / sincronizando / pendiente offline)— porque los usuarios de herramientas como esta ya se han visto perjudicados antes por pérdidas silenciosas de datos, y la confianza se gana con transparencia, no ocultando la mecánica de sincronización.
#Cerrando con los tradeoffs
Si me pidieran defender el mayor costo de este diseño, es la complejidad de ingeniería: los CRDTs y los vector clocks son una inversión real comparados con "llama a una API REST y cachea la respuesta", y solo justificaría esa inversión porque la edición offline multi-dispositivo real es una función estrella, no un añadido de último momento. Para un producto más simple donde offline solo significa "no te caigas en el metro", bajaría con gusto a un diseño mucho más ligero de caché-local-más-cola-de-reintentos. El otro costo es el crecimiento del almacenamiento por el registro de operaciones de solo-anexado — necesitaría un trabajo periódico de compactación que tome una fotografía del estado resuelto y elimine las operaciones ya confirmadas más antiguas que cierta ventana de retención, para que el registro no crezca sin límite en un dispositivo que estuvo offline durante un mes. Dados los requisitos que aclaramos al principio —edición offline real, seguridad real ante conflictos, sincronización multi-dispositivo— respaldaría BD-local-como-verdad más un motor de sincronización guiado por un registro de operaciones con resolución de conflictos apropiada por campo como la forma correcta, y trataría la inversión en CRDTs para el contenido del cuerpo como el único lugar que vale la pena la complejidad extra.