Voltar para o blog

Offline-first no Expo: o que ninguém te conta

Sincronização, resolução de conflitos e as decisões de arquitetura que só aparecem quando o app precisa funcionar sem rede.

·4 min de leitura

Construir um app que funciona sem internet parece um problema de cache. Não é. É um problema de consenso distribuído com um cliente que some por horas e volta achando que está certo.

Esse post é o resumo do que aprendi construindo a camada offline do AgroCRM, onde técnicos de campo passam o dia inteiro em fazendas sem sinal.

O erro que todo mundo comete primeiro

A primeira versão sempre é assim: salva no AsyncStorage, marca um booleano synced, e quando a rede volta manda tudo pro servidor.

TSlib/sync.ts
type Record = {
  id: string;
  data: unknown;
  synced: boolean;
};
 
async function sync(records: Record[]) {
  for (const record of records.filter((r) => !r.synced)) {
    await api.post("/records", record);
  }
}

As três linhas destacadas concentram todos os problemas. É serial, não trata falha parcial e assume que o servidor é a única fonte de verdade.

Isso funciona no happy path e quebra em todos os outros. O que acontece quando:

  • O mesmo registro foi editado no celular e no servidor?
  • O POST deu timeout mas o servidor processou?
  • Dois dispositivos do mesmo usuário sincronizam ao mesmo tempo?

Três decisões que resolvem 90% dos casos

1. O cliente gera o ID

Deixar o servidor gerar o ID força você a ter dois estados: "criado localmente" e "criado de verdade". Com UUID gerado no cliente, um registro nasce com identidade definitiva.

TSlib/record.ts
import { randomUUID } from "expo-crypto";
 
const record = {
  id: randomUUID(),
  createdAt: new Date().toISOString(),
};

Isso também torna o POST idempotente: reenviar o mesmo registro depois de um timeout não cria duplicata, porque o servidor faz upsert pela chave primária.

2. Fila de operações, não fila de estados

Sincronizar o estado final de um registro perde informação. Sincronizar as operações preserva a intenção.

AbordagemEditou nome e depois telefoneResultado no servidor
Fila de estados1 registro com os dois camposSobrescreve edição concorrente
Fila de operações2 operações independentesFaz merge campo a campo

Na prática cada item da fila fica assim:

JSONqueue-item.json
{
  "op": "update",
  "entity": "visita",
  "id": "9f1c...",
  "field": "telefone",
  "value": "+55 19 99999-0000",
  "at": "2026-07-21T14:03:00.000Z"
}

O field é o que permite o merge. Sem ele você só sabe que o registro mudou, não o que mudou.

3. Last-write-wins por campo, não por registro

Conflito de registro inteiro é uma decisão preguiçosa que sempre descarta trabalho de alguém.

Guardar um timestamp por campo custa alguns bytes e evita a pergunta mais chata do suporte: "cadê a alteração que eu fiz ontem?".

Onde a complexidade realmente mora

Não é no algoritmo — é na observabilidade. Quando um técnico liga dizendo que perdeu dados, você precisa responder em minutos, não em dias.

O que salvou o projeto:

  1. Log local de toda operação, com timestamp e resultado
  2. Um botão escondido que exporta esse log
  3. Métrica de quantos itens estão na fila, visível no próprio app

Para inspecionar a fila de um aparelho em campo, o fluxo é sempre o mesmo:

Bash
adb shell run-as com.dolphin.agrocrm cat databases/queue.db > queue.db
sqlite3 queue.db "select op, entity, field, at from queue order by at desc limit 20"

Nada disso é glamouroso. Tudo isso é o que faz o recurso ser confiável.

O que eu faria diferente

Começaria pela camada de sincronização, não pela UI. A modelagem offline vaza para absolutamente tudo — schema, navegação, estados de loading, telas de erro. Tratar isso como "a gente resolve depois" é garantir um refactor caro.

Se quiser ver isso na prática em código aberto, o AstroVista tem uma versão bem mais simples da mesma ideia.