· Hernán Pérez Rodal · Engineering · 6 min read
Arquitetura offline-first: captura de dados em zonas rurais com conectividade intermitente
Na América Latina, o primeiro elo produtivo costuma ter sinal 3G ou pior. Contamos como desenhamos o Captia para funcionar sem conexão — e que tradeoffs assumimos com eventual consistency.

TL;DR — A maioria dos apps assume conectividade. No campo latino-americano você não pode. O Captia, nosso app mobile de captura de dados, é offline-first nativo: os operadores registram CTEs sem sinal e sincronizam depois. Este post conta como o projetamos, que tradeoffs assumimos, e como resolvemos conflicts quando aparecem.
O problema: conectividade NÃO é uma feature opcional
FSMA 204 e EUDR exigem capturar eventos onde ocorrem. Para produtores primários (campo, pesca, gado), isso significa:
- Zonas rurais com 3G intermitente ou sem sinal
- Plantas processadoras onde a cobertura morre em certos pisos
- Operadores se movendo entre lotes/silos/câmaras
Pedir a um operador que “volte quando tiver sinal” para registrar um evento é garantia de que não se registra. Em produção real, ou capta offline — ou não capta.
A Darwin faz tracking de milhões de eventos por ano, a maioria de operações na América Latina. Não podíamos assumir conectividade. Projetamos tudo ao contrário.
Princípios que aplicamos
1. Offline é o default, online é um bonus
O app se usa igual com ou sem conexão. Não há “modo offline” com funcionalidade reduzida. O usuário não precisa pensar no estado de rede.
Criar evento → salvar local → (quando houver rede) → syncO operador nunca vê “erro: sem conexão”. O app simplesmente funciona.
2. Banco de dados local como fonte de verdade temporária
Cada device tem um DB local (SQLite via WatermelonDB) que é a fonte de verdade até que o sync ocorra. Tudo que o operador vê sai dali, não do server.
Isso muda como você projeta as queries: não há “GET /eventos”. Há “leia meu store local”. O sync é um processo separado em background.
3. Eventos como first-class citizens
Em vez de “form submissions”, modelamos tudo como eventos imutáveis com timestamp:
type CriticalTrackingEvent = {
id: string; // UUID gerado localmente
type: 'harvest' | 'processing' | 'shipment' | ...;
timestamp: Date; // quando OCORREU, não quando foi registrado
deviceId: string;
operatorId: string;
payload: EventPayload;
syncStatus: 'pending' | 'synced' | 'conflicted';
}Os eventos não se editam — se corrigem com novos eventos (pattern event sourcing). Isso simplifica a sincronização e é consistent com como funciona compliance (ninguém “edita” um CTE do passado, emite-se uma correção).
4. UUIDs gerados no cliente, não no server
Cada evento tem um UUID v7 gerado no device antes do sync. Isso permite:
- Referenciar o evento offline desde outros eventos
- Anexar fotos/documentos que linkam por UUID
- Sincronizar em qualquer ordem sem dependency issues
O stack
- Mobile: React Native (Expo) para iOS + Android
- Local DB: WatermelonDB (SQLite com reatividade)
- Sync layer: Custom — baseado em operational transform (OT) light, não CRDTs
- Backend: FastAPI recebe batches de eventos, valida, ancora critical events on-chain
- Firebase (Firestore): para alguns metadados dinâmicos (catálogo de produtos, regras) que precisam de update near-realtime
Os tradeoffs reais
Eventual consistency, não strong consistency
Dois operadores em lotes distintos podem registrar eventos “ao mesmo tempo” (nos seus relógios) e a ordem que chega ao server pode não respeitar wall-clock.
Para compliance, isso está OK porque:
- Cada evento tem timestamp do device (clock local)
- Cada evento tem timestamp do server quando foi recebido
- A rastreabilidade se reconstrói por lote (não por ordem global)
Exceção: eventos que requerem serialização global (ex: transferência de custódia) usam locking via server — se não há rede, o evento fica em “pending lock” e se completa ao sincronizar.
Conflict resolution: last-write-wins com auditoria
Quando dois devices editam o mesmo recurso (raro mas acontece), aplicamos last-write-wins baseado em server timestamp ao receber. Mas:
- Ambas as versões ficam registradas no audit trail
- O conflict é marcado na UI do operador para revisão humana se importa
- Para compliance, guarda-se evidência de ambos os eventos
Não é perfeito, mas é transparente — não silenciamos dados, os marcamos.
Dados sensíveis não vão offline
Há coisas que não guardamos no device:
- Preços e terms comerciais
- Info de outros clientes
- Credenciais expiradas
Para esses, se não há rede, a operação é negada. Trade-off aceitável porque são edge cases operacionais, não fluxo core de captura.
Sync protocol: 80% do trabalho
Quando recuperamos conectividade:
- Push local → server: batch de eventos pending, com checksum
- Server validates: schema, auth, business rules (batch atomic)
- Server responds:
{ accepted: [...], rejected: [...], conflicts: [...] } - Client reconciles: marca localmente como synced, conflict, ou retry
- Pull server → local: deltas que o server tenha (outros devices que já sincronizaram)
Detalhes críticos:
- Batching por tamanho + tempo — agrupamos eventos para reduzir requests mas sem esperar demais
- Exponential backoff com jitter — se falha, retentamos sem DDoS ao server
- Checkpoints durante o batch — se falha o batch no meio, retomamos desde o último evento confirmado (não reenviamos tudo)
- Observabilidade cliente + server — logs de quando o evento ocorreu, quando se tentou sync, quando se completou, quanto demorou
O que não funcionou
V0: sync automático agressivo — app detectava sinal e sincronizava imediatamente em background. Problema: consumia data plans caros de operadores rurais. Mudamos para sync on-demand + WiFi-only por default com opção de override.
CRDTs completos — avaliamos CRDTs para multi-device collab. Over-engineering para nosso caso (a maioria dos eventos são single-device, single-user). Ficamos com eventual consistency + locks seletivos.
Long-running transactions offline — alguns fluxos tentavam fazer “transações multi-evento atômicas” offline. Resultado: UI travava se algo falhasse. Simplificamos a eventos atômicos por unidade mínima.
O que sim funcionou
WatermelonDB como local DB — performance excelente mesmo com centenas de milhares de eventos no device.
UUIDs v7 — ordenáveis naturalmente por timestamp, colisão impossível na prática, servem como primary key tanto local como remote.
Push notifications do server quando há sync pendente — o operador sabe que tem que abrir o app e conectar ao WiFi no escritório.
“Mark as synced” local após confirmação do server — prevenimos ghost data (eventos que o server tem mas o device acha que ainda não).
Tooling de debug cliente — tela oculta que mostra quais eventos estão pending, quando se tentou sync, que erros apareceram. Os operadores e nosso support team a usam daily.
Lessons learned
- Offline-first é arquitetura, não feature — colocar depois custa 5x mais
- Event sourcing simplifica tudo — imutáveis + timestamps + sync eventual
- UUIDs client-side são indispensáveis para offline
- Eventual consistency é aceitável em compliance se seu audit trail é explícito
- Conflict resolution honesto > silencioso — marque conflicts, não esconda dados
E agora?
Estamos explorando sync peer-to-peer via Bluetooth/WiFi local para quando múltiplos operadores estejam em zona sem internet mas perto entre si. Casos como plantas pesqueiras em alto-mar ou cooperativas de produtores em zonas remotas.
Se você está construindo para contextos com conectividade frágil, meu conselho é: projete como se nunca houvesse conexão. O que vier por rede depois é bonus. Ao contrário nunca funciona.
Precisa de captura de dados em operações com conectividade frágil? Vamos conversar — é uma das nossas especialidades.




