· 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.

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) → syncEl 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:
- Push local → server: batch de eventos pendientes, con checksum
- Server validates: schema, auth, business rules (batch atomic)
- Server responds:
{ accepted: [...], rejected: [...], conflicts: [...] } - Client reconciles: marca locally como synced, conflict, o retry
- 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
- Offline-first es arquitectura, no feature — meterlo después cuesta 5x más
- Event sourcing simplifica todo — inmutables + timestamps + sync eventual
- UUIDs client-side son indispensables para offline
- Eventual consistency es aceptable en compliance si tu audit trail es explícito
- 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.




