A sincronização offline-first é uma arquitetura em que o app lê e escreve primeiro em um store local e só depois reconcilia com o servidor. A rede deixa de ser dependência e vira processo de segundo plano — toda tela renderiza a partir do dispositivo, todo toque grava no dispositivo, e uma camada de sync acerta as contas com o backend quando a conexão permite. A parte difícil nunca foi guardar dados localmente; é o que acontece quando duas cópias da verdade discordam.
Principais pontos
| Pergunta | Resposta |
|---|---|
| O movimento central | As telas falam com um store local no dispositivo; a rede sincroniza em segundo plano |
| As duas posturas | Store local como buffer (o servidor é dono da verdade) ou como réplica (a verdade é negociada) |
| Escritas offline | Fila de outbox durável → replay em ordem na reconexão |
| Conflitos | Última escrita vence · merge por campo · CRDTs — escolha por campo, não por app |
| O custo honesto | A maquinaria de sync é complexidade permanente — gaste onde o offline importa |
Fixar, ler, enfileirar: o loop offline em código
A versão do padrão nos SDKs mobile — fixe o que as telas precisam, enfileire o que os usuários mudam:
// JavaScript — Back4app JS SDK with the Local Datastore enabled
Parse.enableLocalDatastore();
// Read: serve the screen from the local store — instant, works offline
const query = new Parse.Query('Task');
query.fromLocalDatastore();
render(await query.find());
// Write: durable locally now, queued for the server automatically
const task = new Parse.Object('Task');
task.set('title', 'Inspect site 14');
task.set('done', false);
await task.pin(); // survives restarts, feeds local reads
task.saveEventually(); // replays when the network returns // Flutter / Dart — Back4app Flutter SDK with local storage enabled
// Parse().initialize(..., coreStore: await CoreStoreSembastImp.getInstance())
// Read: serve the screen from pinned data — instant, works offline
final pinned = await ParseObject('Task').fromPin('openTasks');
render(pinned);
// Write: durable locally now, synced when the network allows
final task = ParseObject('Task')
..set('title', 'Inspect site 14')
..set('done', false);
await task.pin(); // survives restarts, feeds local reads
final response = await task.save();
if (!response.success) retryLater(task); // replay from the outbox // iOS / Swift — Back4app iOS SDK with the Local Datastore enabled
// (in the app delegate) Parse.enableLocalDatastore()
// Read: serve the screen from the local store — instant, works offline
let query = PFQuery(className: "Task")
query.fromLocalDatastore()
query.findObjectsInBackground { tasks, _ in
render(tasks)
}
// Write: durable locally now, queued for the server automatically
let task = PFObject(className: "Task")
task["title"] = "Inspect site 14"
task["done"] = false
task.pinInBackground() // survives restarts, feeds local reads
task.saveEventually() // replays when the network returns // Android / Kotlin — Back4app Android SDK with the Local Datastore enabled
// (before Parse.initialize) Parse.enableLocalDatastore(context)
// Read: serve the screen from the local store — instant, works offline
val query = ParseQuery.getQuery<ParseObject>("Task")
query.fromLocalDatastore()
query.findInBackground { tasks, _ ->
render(tasks)
}
// Write: durable locally now, queued for the server automatically
val task = ParseObject("Task")
task.put("title", "Inspect site 14")
task.put("done", false)
task.pinInBackground() // survives restarts, feeds local reads
task.saveEventually() // replays when the network returns Duas chamadas carregam a arquitetura inteira. Fixar (pin) torna objetos duráveis e consultáveis no dispositivo — a mesma interface de consulta do servidor, apontada para o armazenamento local (Local Datastore nos SDKs acima). Save-eventually aceita a escrita agora e assume a entrega depois. Todo o resto deste artigo é o que acontece entre essas duas chamadas.
Buffer ou fonte da verdade? A decisão por trás da decisão
Todo design offline-first escolhe silenciosamente uma entre duas posturas para o store local, e quase toda dor de sync vem de não ter escolhido de propósito.
Store local como buffer. O servidor continua autoritativo; o dispositivo guarda uma cópia de trabalho mais uma outbox de intenção. Conflitos se resolvem a favor do servidor ou por política simples, e um dispositivo sempre pode ser consertado com um novo pull. É o padrão certo para apps de negócio — checklists de CRM, inspeções em campo, entrada de pedidos — porque o raciocínio permanece simples: a verdade mora em um lugar só, os dispositivos apenas ficam atrasados em relação a ela.
Store local como réplica. A verdade é negociada entre pares e o servidor é uma réplica entre várias — a postura de editores colaborativos e apps de notas que prometem semântica de “faz merge de tudo”. Isso compra resiliência e confiança do usuário ao preço de maquinaria de sistemas distribuídos de verdade: version vectors para detectar concorrência, funções de merge por tipo de dado e consistência eventual como a garantia mais forte que dá para prometer honestamente.
A postura se escolhe por conjunto de dados, não por app: o mesmo app de serviço em campo pode tratar as ordens de serviço do próprio técnico como réplica (elas precisam ser editáveis o dia todo no subsolo) e o catálogo de peças como buffer read-through.
O loop de sync, de ponta a ponta
O loop tem uma metade de saída e uma de entrada, e elas se encontram na resolução de conflitos.
Saída: escritas enfileiradas e replay. Escritas offline são efetivadas localmente e anexadas a uma outbox durável — a interface reflete a intenção imediatamente, rotulada honestamente como “pendente” onde isso importa. Na reconexão, a fila é reexecutada em ordem. A engenharia está nos modos de falha: erros transitórios são retentados com backoff; rejeições de permissão e validação precisam aparecer para o usuário, não ser retentadas eternamente; e as operações devem ser idempotentes, porque um acknowledgment perdido significa que a mesma operação pode chegar duas vezes. Reexecutar “marcar status como concluído” duas vezes é inofensivo; reexecutar “incrementar estoque em 3” duas vezes é bug.
Entrada: pull de deltas. Rebuscar tudo na reconexão desperdiça banda e bateria; sync de produção puxa mudanças desde um checkpoint — uma marca d’água de updated-at nos designs simples, um número de sequência do servidor ou um change feed nos mais robustos. Os mesmos eventos que alimentam as live queries quando o app está online servem também como stream de deltas de entrada, e é por isso que sync offline e sync em tempo real são um contínuo, não duas features.
Última escrita vence vs. merge vs. CRDTs
| Estratégia | Como resolve | Perde dados? | Custo | Boa para |
|---|---|---|---|---|
| Última escrita vence (last-write-wins) | O timestamp/versão mais novo fica com o registro | Sim — em silêncio | Trivial | Registros de escritor único, toggles, status |
| Merge por campo | Edições em campos distintos sobrevivem; colisões no mesmo campo caem na política | Só em colisões no mesmo campo | Moderado | Formulários e registros editados por poucas pessoas |
| Resolução manual | Guarda as duas versões; um humano escolhe | Não | Interface + fluxo | Registros de alto risco, consoles de sync |
| CRDT / CRDT-lite | Tipos de dado cujas operações fazem merge de forma determinística | Não | Disciplina de modelagem, peso da biblioteca | Contadores, conjuntos, estruturas colaborativas |
Duas observações honestas sobre essa tabela. Primeira: timestamps são mais frágeis do que parecem — relógios de dispositivo derivam, então um last-write-wins sério usa a hora de recebimento no servidor ou versões lógicas (version vectors quando edições concorrentes precisam ser detectadas e não adivinhadas). Segunda: o ponto ideal na prática, em mobile, é o CRDT-lite: bibliotecas CRDT completas são maquinaria pesada, mas roubar a ideia campo a campo é barato — modele uma quantidade como operações de incremento em vez de um total armazenado, tags como conjuntos de add/remove em vez de um array, e esses campos simplesmente param de conflitar. A estratégia se escolhe por campo; apps que adotam uma política global costumam ter escolhido errado para algum campo.
Casos de uso comuns
- Operações em campo — inspeções, entregas, serviços de utilidade pública: o local do trabalho é exatamente onde a cobertura morre, então a captura precisa ser local e o sync, oportunista.
- Notas e produtividade pessoal — um usuário, vários dispositivos: conflitos são raros e quase sempre autoinfligidos, o que torna as políticas de merge tratáveis.
- PDV e apps de quiosque — a fila precisa continuar andando durante o reboot do roteador; as transações são reexecutadas quando o link volta.
- Apps de viagem — itinerários, mapas e bilhetes consumidos justamente onde a conexão é pior: aviões, trens, roaming.
- Produtos para mercados emergentes e baixa banda — conexões intermitentes e tarifadas fazem do sync incremental em segundo plano o único design respeitoso, seja qual for a combinação de plataformas.
Você deveria construir offline-first? Matriz de decisão
| Construa offline-first quando… | Fique online-first quando… |
|---|---|
| Os usuários trabalham onde a cobertura falha — campo, trânsito, subsolo | Os usuários estão praticamente sempre conectados (web desktop, ferramentas de escritório) |
| Um toque bloqueado custa dinheiro ou confiança (PDV, captura em campo) | A correção exige um estado autoritativo agora — pagamentos, reservas, estoque |
| Os dados são registros do próprio usuário, com poucos escritores concorrentes | Os dados são calculados no servidor ou compartilhados e quentes (feeds, dashboards) — faça cache de leitura e mantenha as escritas online |
| As escritas toleram reconciliação posterior | Dois dispositivos agindo sobre verdade velha é um risco real no mundo físico |
| Você consegue bancar a manutenção permanente da camada de sync | Um estado de carregamento é honestamente suficiente |
O meio-termo é legítimo e comum: faça cache das leituras para telas instantâneas, permita escritas offline só nos poucos conjuntos de dados que realmente precisam, e mantenha o resto online-first atrás de estados de conectividade honestos.
Limitações e trade-offs
- Resolução de conflitos é decisão de produto fantasiada de engenharia. “Qual edição sobrevive?” não tem resposta tecnicamente correta — alguém precisa assumir a política campo a campo, e “manter a mais nova em silêncio” é uma escolha com vítima.
- A outbox é uma fila de passivos. Escritas pendentes de um dispositivo que reaparece depois de duas semanas podem ser reexecutadas em um mundo que mudou; políticas de expiração e validação no replay são obrigatórias, não paranoia.
- Bugs de sync são a pior classe de bug que dá para comprar. Só se reproduzem sob interleavings específicos de edições e conectividade — invista em testes de replay determinístico e em uma superfície visível de status de sync, ou depure por anedota para sempre.
- A verdade local envelhece. As telas precisam ser honestas sobre defasagem onde isso importa (preços, disponibilidade) — “atualizado há 2 horas” é feature, não pedido de desculpas.
- Armazenamento e identidade pesam. Stores de dispositivo precisam de migrações como qualquer banco, e registros criados offline precisam de identidades geradas no cliente que sobrevivam à viagem até o servidor sem duplicar.
Sincronização offline-first no Back4app
O Back4app é uma plataforma open-source de Backend as a Service (BaaS) que combina banco de dados gerenciado, APIs REST e GraphQL geradas automaticamente, autenticação, armazenamento de arquivos e funções serverless com Cloud Code. Seus SDKs mobile entregam a postura de buffer pronta: o Local Datastore fixa os resultados de qualquer consulta para leitura offline pela mesma API de consulta, o saveEventually dá a toda escrita uma outbox durável com replay ordenado, e triggers beforeSave no Cloud Code são a casa natural da política de conflito no servidor — checagem de versão, regras de merge, validação no replay — aplicada em todo caminho de escrita. Combine com o stream de Live Query para os deltas de entrada enquanto o app está online, e o loop de sync do diagrama acima vira configuração mais política, não um subsistema para construir.
Perguntas frequentes
O que é um app offline-first?
Um app cujas telas leem e escrevem em um banco de dados no próprio dispositivo, com a rede rebaixada a um detalhe de sincronização. Todo toque funciona em modo avião porque nada no caminho da interação espera pelo servidor; uma camada de sync em segundo plano reconcilia o store local com o backend sempre que a conexão permite. O contrário são os apps online-first, que exibem spinners até o servidor responder.
Como funciona a sincronização de dados offline?
Em dois loops. Saída: as escritas locais caem no store do dispositivo e em uma fila de outbox durável; quando a rede volta, a fila é reexecutada contra o servidor, em ordem. Entrada: o cliente puxa as mudanças desde o último sync — por timestamp, número de sequência ou change feed — e as aplica localmente. A resolução de conflitos fica onde os dois loops se encontram, decidindo o que acontece quando os dois lados editaram o mesmo registro.
O que é last-write-wins e quando ele é seguro?
A política de conflito mais simples: compare timestamps ou versões e mantenha a escrita mais nova, descartando a outra em silêncio. É segura quando as edições são de registro inteiro e de baixo risco — um toggle de configuração, uma flag de status — ou quando o normal é ter um único escritor por registro. É perigosa em documentos compartilhados e contadores, onde "descartar a outra edição" significa perder o trabalho de alguém.
Como os CRDTs resolvem conflitos?
Tornando o conflito irrepresentável: um CRDT restringe cada campo a operações que fazem merge de forma determinística, independentemente da ordem de chegada — contadores que só crescem, conjuntos de add/remove, registradores de último escritor. Duas réplicas que trocam estado sempre convergem sem coordenação. O custo é disciplina de modelagem: seus dados precisam ser expressos no vocabulário CRDT, e bibliotecas completas são pesadas demais para o app mobile típico de formulários e listas.
O que acontece com as escritas feitas offline?
Elas entram na fila. Uma outbox durável registra cada operação pendente, e o store local reflete a escrita imediatamente, para que a interface continue fiel à intenção do usuário. Na reconexão, a fila é reexecutada em ordem; falhas exigem política — reprocessar erros transitórios, mas devolver rejeições de permissão e de validação para a interface em vez de tentar para sempre. Operações idempotentes tornam o replay seguro quando um acknowledgment se perde.
Offline-first é a mesma coisa que cache?
Não — a diferença é a direção. Um cache acelera leituras: o servidor continua sendo a fonte da verdade e entradas velhas são apenas rebuscadas. Offline-first também aceita escritas localmente, e é isso que cria divergência entre dispositivo e servidor e obriga a maquinaria pesada — filas de outbox, replay, resolução de conflitos. Cache de leitura é otimização de performance; offline-first é um compromisso de arquitetura de dados.
Quando não vale a pena construir offline-first?
Quando a correção depende de um único estado autoritativo: pagamentos, reservas, baixa de estoque, qualquer coisa em que dois dispositivos agindo sobre dados velhos criem gasto em dobro no mundo real. Também quando os dados são calculados no servidor e majoritariamente de leitura — um feed de notícias degrada bem com um cache simples. A maquinaria de sync é um imposto permanente; pague onde as condições de campo ou a expectativa do usuário exigirem.
Como os SDKs mobile fixam dados localmente?
Fixar (pin) marca objetos para armazenamento durável no dispositivo: registros fixados sobrevivem a reinícios, podem ser consultados offline pela mesma interface de consulta usada no servidor e permanecem ligados às suas identidades no servidor, para que syncs posteriores os atualizem no lugar. Combinado com escritas enfileiradas — a semântica de save-eventually —, o pinning entrega uma camada offline funcional sem escrever um schema de banco à mão no dispositivo.