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

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) → sync

O 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:

  1. Push local → server: batch de eventos pending, com checksum
  2. Server validates: schema, auth, business rules (batch atomic)
  3. Server responds: { accepted: [...], rejected: [...], conflicts: [...] }
  4. Client reconciles: marca localmente como synced, conflict, ou retry
  5. 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

  1. Offline-first é arquitetura, não feature — colocar depois custa 5x mais
  2. Event sourcing simplifica tudo — imutáveis + timestamps + sync eventual
  3. UUIDs client-side são indispensáveis para offline
  4. Eventual consistency é aceitável em compliance se seu audit trail é explícito
  5. 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.

Compartir:
Back to Blog

Related Posts

View All Posts »