Offline-first sem perder dados: fila de envio, idempotência e conflito por versão
Funcionar sem internet é a parte fácil. O difícil é sincronizar depois sem duplicar, sem perder e sem sobrescrever o trabalho de outra pessoa. As três peças que usamos e onde cada uma costuma falhar.
Orçamento de obra é feito no escritório, mas também no canteiro, com sinal ruim ou sem sinal nenhum. Por isso o Click Orçamentos grava tudo primeiro no aparelho e sincroniza com o servidor quando a conexão volta. Guardar dados offline é a parte fácil. A sincronização é onde aparecem os registros duplicados, as edições perdidas e os dados sobrescritos. Ela se apoia em três peças, e cada uma tem um jeito clássico de falhar.
1. Fila de envio: a tela nunca espera a rede
Toda alteração vira duas escritas locais, na mesma transação: a mudança no dado e um registro numa fila de operações pendentes (o padrão outbox). A interface lê sempre do banco local; um processo em segundo plano esvazia a fila quando há conexão.
interface OperacaoPendente {
id: string // UUID gerado no aparelho, nunca no servidor
entidade: "orcamento" | "servico"
entidadeId: string
versaoBase: number // versão que o usuário estava vendo ao editar
mudancas: Record<string, unknown>
criadaEm: string
}O detalhe que importa é o id gerado no cliente. É ele que permite reenviar a mesma
operação com segurança, que é o assunto da próxima peça.
No navegador, a fila vive no IndexedDB; no app de desktop, num SQLite local. A interface é a mesma, e a lógica de sincronização é compartilhada entre os dois.
2. Idempotência: reenviar não pode duplicar
Rede móvel falha no pior momento: o servidor recebe o lote, grava, e a resposta se perde no caminho. O cliente não sabe se deu certo e reenvia. Sem proteção, o mesmo serviço aparece duas vezes no orçamento.
A proteção é registrar no servidor cada operação aplicada, na mesma transação da mudança:
-- Se esta operação já foi aplicada, não retorna linha nenhuma e pulamos a mudança
INSERT INTO operacoes_aplicadas (operacao_id, empresa_id)
VALUES ($1, $2)
ON CONFLICT (operacao_id) DO NOTHING
RETURNING operacao_id;Se o INSERT não retorna linha, a operação já foi processada: o servidor responde
"aplicada" de novo e segue para a próxima. Reenviar passa a ser sempre seguro, e o
cliente pode tentar de novo quantas vezes precisar.
3. Conflito por versão: não sobrescrever o trabalho dos outros
Duas pessoas editam o mesmo serviço offline. A primeira sincroniza; quando a segunda sincronizar, a alteração dela não pode simplesmente apagar a da primeira. Cada registro carrega um número de versão, e a escrita só acontece se a versão não mudou desde que o usuário começou a editar:
UPDATE servicos
SET quantidade = $3, versao = versao + 1
WHERE id = $1 AND versao = $2
RETURNING versao;Zero linhas afetadas significa conflito. O servidor devolve o estado atual, e o cliente decide: reaplicar por cima, descartar ou mostrar as duas versões para o usuário escolher.
A falha clássica aqui é sutil: incrementar a versão em toda escrita e nunca conferir a versão recebida. O campo existe, os números sobem, e mesmo assim a última escrita sempre vence. Conferir a versão tem que valer em todos os caminhos que escrevem: na sincronização e também nas rotas HTTP comuns.
Puxar mudanças: cursor, não relógio
Para baixar o que mudou no servidor, é tentador pedir "tudo alterado depois de
ultimaSincronizacao". O problema é que relógios divergem entre máquinas, e duas
alterações podem ter o mesmo carimbo de tempo. Uma delas fica para trás para sempre.
Mais seguro é um número de sequência crescente, atribuído pelo banco a cada alteração. O cliente guarda o último número que recebeu e pede "tudo depois de 18342". Não há empate e não depende de relógio.
O endpoint de sincronização é uma porta de entrada
Num sistema com várias empresas, o endpoint de sincronização recebe lotes com dezenas de entidades de uma vez, e é fácil validar só o lote e não cada item. Toda entidade do lote precisa ter a posse checada, como em qualquer outra rota. No nosso caso, o isolamento entre empresas também é garantido pelo Postgres, com Row-Level Security: mesmo que uma checagem na aplicação falhe, o banco recusa a escrita.
O que offline-first custa
- Conflitos precisam de uma tela. Detectar é a parte barata; mostrar ao usuário duas versões e deixar que ele escolha dá trabalho de produto.
- O navegador pode apagar seus dados. O armazenamento local é "melhor esforço" por
padrão e pode ser removido sob pressão de espaço. Peça armazenamento persistente com
navigator.storage.persist(). No Safari, dados gravados por script podem ser apagados depois de 7 dias de uso do navegador sem interação com o site; apps adicionados à tela de início contam esses dias pelo próprio uso, então quem usa o app com frequência não é afetado. - Os testes ficam mais difíceis. Cenários de queda no meio do envio e de edição concorrente precisam ser simulados de propósito, porque não aparecem no uso normal.
Se o seu usuário está sempre conectado, nada disso se paga: uma aplicação online comum, com tratamento de erro decente, é mais simples e mais barata de manter.
Seu produto precisa funcionar em campo, com sinal ruim? Fale com a gente.