LGMdevs
Voltar ao blog
Equipe LGMdevs

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.

ArquiteturaBackendPostgreSQL

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.

Fontes