· Hernán Pérez Rodal · Engineering  · 6 min read

Offline-first architecture: captura de datos en zonas rurales con conectividad intermitente

En América Latina, el primer eslabón productivo suele tener señal 3G o peor. Contamos cómo diseñamos Captia para que funcione sin conexión — y qué tradeoffs asumimos con eventual consistency.

En América Latina, el primer eslabón productivo suele tener señal 3G o peor. Contamos cómo diseñamos Captia para que funcione sin conexión — y qué tradeoffs asumimos con eventual consistency.

TL;DR — La mayoría de apps asumen conectividad. En el campo latinoamericano no podés. Captia, nuestra app mobile de captura de datos, es offline-first nativo: los operadores registran CTEs sin señal, y sincronizan después. Este post cuenta cómo lo diseñamos, qué tradeoffs asumimos, y cómo resolvemos conflicts cuando aparecen.

El problema: conectividad NO es un feature opcional

FSMA 204 y EUDR exigen capturar eventos donde ocurren. Para productores primarios (campo, pesca, ganado), eso significa:

  • Zonas rurales con 3G intermitente o sin señal
  • Plantas procesadoras donde la cobertura muere en ciertos pisos
  • Operarios moviéndose entre lotes/silos/cámaras

Pedirle a un operador que “vuelva cuando tenga señal” para registrar un evento es garantía de que no se registra. En producción real, o captás offline — o no captás.

Darwin hace tracking de millones de eventos por año, mayoría de operaciones en Latinoamérica. No podíamos asumir conectividad. Diseñamos todo al revés.

Principios que aplicamos

1. Offline es el default, online es un bonus

La app se usa igual con o sin conexión. No hay “modo offline” con funcionalidad reducida. El usuario no tiene que pensar en el estado de red.

Crear evento → guardar local → (cuando haya red) → sync

El operador nunca ve “error: sin conexión”. La app simplemente funciona.

2. Local database como fuente de verdad temporal

Cada device tiene una DB local (SQLite via WatermelonDB) que es la fuente de verdad hasta que ocurra el sync. Todo lo que el operador ve sale de ahí, no del server.

Esto cambia cómo diseñás las queries: no hay “GET /eventos”. Hay “lee mi store local”. El sync es un proceso separado en background.

3. Eventos como first-class citizens

En vez de “form submissions”, modelamos todo como eventos inmutables con timestamp:

type CriticalTrackingEvent = {
  id: string;            // UUID generado localmente
  type: 'harvest' | 'processing' | 'shipment' | ...;
  timestamp: Date;       // cuando OCURRIÓ, no cuando se registró
  deviceId: string;
  operatorId: string;
  payload: EventPayload;
  syncStatus: 'pending' | 'synced' | 'conflicted';
}

Los eventos no se editan — se corrigen con nuevos eventos (patrón event sourcing). Esto simplifica la sincronización y es consistent con cómo funciona compliance (nadie “edita” un CTE del pasado, se emite una corrección).

4. UUIDs generados en cliente, no en server

Cada evento tiene un UUID v7 generado en el device antes del sync. Esto permite:

  • Referenciar el evento offline desde otros eventos
  • Adjuntar fotos/documentos que linkean por UUID
  • Sincronizar en cualquier orden sin dependency issues

El stack

  • Mobile: React Native (Expo) para iOS + Android
  • Local DB: WatermelonDB (SQLite con reactividad)
  • Sync layer: Custom — basado en operational transform (OT) light, no CRDTs
  • Backend: FastAPI recibe batches de eventos, valida, anclada critical events on-chain
  • Firebase (Firestore): para algunos metadatos dinámicos (catálogo de productos, reglas) que sí necesitan near-realtime update

Los tradeoffs reales

Eventual consistency, no strong consistency

Dos operadores en lotes distintos pueden registrar eventos “al mismo tiempo” (a sus relojes) y el orden que llega al server puede no respetar wall-clock.

Para compliance, esto está OK porque:

  • Cada evento tiene timestamp del device (local clock)
  • Cada evento tiene timestamp del server cuando se recibió
  • La trazabilidad se reconstruye por lote (no por orden global)

Excepción: eventos que requieren serialización global (ej: transferencia de custodia) usan locking via server — si no hay red, el evento queda en “pendiente lock” y se completa al sincronizar.

Conflict resolution: last-write-wins con auditoría

Cuando dos devices editan el mismo recurso (raro pero pasa), aplicamos last-write-wins basado en server timestamp al recibir. Pero:

  • Ambas versiones quedan registradas en audit trail
  • El conflict se marca en UI del operador para revisión humana si importa
  • Para compliance, se guarda evidencia de ambos eventos

No es perfecto, pero es transparente — no silenciamos data, la marcamos.

Datos sensibles no se van offline

Hay cosas que no guardamos en el device:

  • Precios y terms comerciales
  • Info de otros clientes
  • Credenciales expiradas

Para esos, si no hay red, la operación se deniega. Trade-off aceptable porque son edge cases operativos, no flujo core de captura.

Sync protocol: el 80% del trabajo

Cuando recuperamos conectividad:

  1. Push local → server: batch de eventos pendientes, con checksum
  2. Server validates: schema, auth, business rules (batch atomic)
  3. Server responds: { accepted: [...], rejected: [...], conflicts: [...] }
  4. Client reconciles: marca locally como synced, conflict, o retry
  5. Pull server → local: deltas que el server tenga (otros devices que ya sincronizaron)

Detalles críticos:

  • Batching por tamaño + tiempo — agrupamos eventos para reducir requests pero sin esperar demasiado
  • Exponential backoff con jitter — si falla, reintentamos sin DDoS al server
  • Checkpoints durante el batch — si falla el batch en el medio, retomamos desde el último evento confirmado (no reenviamos todo)
  • Observabilidad cliente + server — logs de cuándo ocurrió el evento, cuándo se intentó sync, cuándo se completó, cuánto tardó

Lo que no funcionó

V0: sync automático agresivo — app detectaba señal y syncaba inmediatamente en background. Problema: consumía data plans caros de operadores rurales. Cambiamos a sync on-demand + WiFi-only por default con opción de override.

CRDTs completos — evaluamos CRDTs para multi-device collab. Over-engineering para nuestro caso (la mayoría de eventos son single-device, single-user). Nos quedamos con eventual consistency + locks selectivos.

Long-running transactions offline — algunos flujos intentaban hacer “transacciones multi-evento atomicas” offline. Resultado: UI se colgaba si algo fallaba. Simplificamos a eventos atómicos por unidad mínima.

Lo que sí funcionó

WatermelonDB como local DB — performance excelente incluso con cientos de miles de eventos en device.

UUIDs v7 — ordenables naturalmente por timestamp, colisión imposible en la práctica, sirven como primary key tanto local como remote.

Push notifications del server cuando hay sync pendiente — el operador sabe que tiene que abrir la app y conectar a WiFi en la oficina.

“Mark as synced” local tras confirmación server — prevenimos ghost data (eventos que el server tiene pero el device cree que aún no).

Tooling de debug cliente — pantalla oculta que muestra qué eventos están pending, cuándo se intentó sync, qué errores aparecieron. Los operadores y nuestro support team la usan daily.

Lessons learned

  1. Offline-first es arquitectura, no feature — meterlo después cuesta 5x más
  2. Event sourcing simplifica todo — inmutables + timestamps + sync eventual
  3. UUIDs client-side son indispensables para offline
  4. Eventual consistency es aceptable en compliance si tu audit trail es explícito
  5. Conflict resolution honesto > silencioso — marcá conflicts, no escondas data

¿Y ahora?

Estamos explorando sync peer-to-peer via Bluetooth/local WiFi para cuando múltiples operadores estén en zona sin internet pero cerca entre sí. Casos como plantas pesqueras en alta mar o cooperativas de productores en zonas remotas.

Si estás construyendo para contextos con conectividad frágil, mi consejo es: diseñá como si no hubiera conexión nunca. Lo que venga por red después es bonus. Al revés nunca funciona.


¿Necesitás captura de datos en operaciones con conectividad frágil? Hablemos — es una de nuestras especialidades.

Compartir:
Back to Blog

Related Posts

View All Posts »