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.
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.
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
POSTdeu 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.
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.
| Abordagem | Editou nome e depois telefone | Resultado no servidor |
|---|---|---|
| Fila de estados | 1 registro com os dois campos | Sobrescreve edição concorrente |
| Fila de operações | 2 operações independentes | Faz merge campo a campo |
Na prática cada item da fila fica assim:
{
"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:
- Log local de toda operação, com timestamp e resultado
- Um botão escondido que exporta esse log
- 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:
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.