Web apps offline-first com sincronização confiável
1. Fundamentos do Offline-First
A arquitetura offline-first representa uma mudança paradigmática no desenvolvimento de aplicações web. Em vez de tratar a conectividade como premissa, ela assume que a rede é intermitente e imprevisível. O princípio central é: o dispositivo local é a fonte primária de dados, e o servidor é um repositório de sincronização.
Diferentemente de abordagens online-only (que falham sem rede) ou cache simples (que apenas armazena respostas HTTP), o offline-first gerencia estado completo localmente. Isso significa que operações de leitura e escrita ocorrem primeiro no armazenamento local, e a sincronização com o servidor acontece de forma assíncrona.
Casos de uso críticos incluem:
- Aplicações móveis em áreas rurais ou com conectividade instável
- Sistemas de coleta de dados em campo (agricultura, logística, saúde)
- Aplicações colaborativas que precisam funcionar durante quedas de rede
2. Armazenamento Local e Persistência de Dados
IndexedDB
O IndexedDB é a espinha dorsal do armazenamento offline estruturado. Ele oferece transações atômicas, índices para consultas eficientes e suporte a grandes volumes de dados.
// Exemplo: Abrindo banco IndexedDB e criando um objeto de armazenamento
const request = indexedDB.open('MeuAppDB', 1);
request.onupgradeneeded = (event) => {
const db = event.target.result;
const store = db.createObjectStore('tarefas', { keyPath: 'id' });
store.createIndex('usuario', 'usuarioId', { unique: false });
store.createIndex('timestamp', 'atualizadoEm', { unique: false });
};
request.onsuccess = (event) => {
const db = event.target.result;
const transaction = db.transaction('tarefas', 'readwrite');
const store = transaction.objectStore('tarefas');
store.add({ id: 'task-001', titulo: 'Comprar insumos', usuarioId: 'user-1', atualizadoEm: Date.now() });
};
Cache API e Service Workers
Service Workers interceptam requisições de rede e implementam estratégias como "Cache First" ou "Network First".
// Service Worker com estratégia Cache First para assets estáticos
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request).then((cachedResponse) => {
return cachedResponse || fetch(event.request).then((response) => {
return caches.open('v1').then((cache) => {
cache.put(event.request, response.clone());
return response;
});
});
})
);
});
Alternativas
- localStorage: limitado a 5-10 MB, síncrono e sem suporte a índices — inadequado para dados estruturados complexos.
- OPFS (Origin Private File System): ideal para arquivos grandes (imagens, vídeos) com acesso de alta performance via File System Access API.
3. Modelagem de Dados para Sincronização
CRDTs e OT
CRDTs (Conflict-free Replicated Data Types) permitem que múltiplos dispositivos editem dados simultaneamente sem coordenação central, garantindo que todos os nós cheguem ao mesmo estado final.
// Exemplo conceitual de CRDT tipo LWW-Register (Last Writer Wins)
const estadoLocal = {
valor: 'texto original',
timestamp: 1000,
idDispositivo: 'device-1'
};
// Operação de escrita local
function escreverLocal(novoValor) {
estadoLocal.valor = novoValor;
estadoLocal.timestamp = Date.now();
adicionarOperacaoFila({ tipo: 'write', valor: novoValor, timestamp: estadoLocal.timestamp });
}
// Merge ao receber atualização remota
function mergeRemoto(dadosRemotos) {
if (dadosRemotos.timestamp > estadoLocal.timestamp) {
estadoLocal.valor = dadosRemotos.valor;
estadoLocal.timestamp = dadosRemotos.timestamp;
}
}
Versionamento Local
Cada registro deve conter metadados de versão:
- Timestamp: momento da última modificação
- Vetor de relógio (vector clock): mapa de versões por dispositivo
- Número de sequência: incrementado a cada operação local
4. Estratégias de Sincronização
Sincronização Pull
O cliente consulta periodicamente o servidor por mudanças:
// Polling com backoff exponencial
let tentativas = 0;
const maxTentativas = 5;
function sincronizarPull() {
fetch('/api/sync/changes?since=' + ultimoTimestamp)
.then(response => response.json())
.then(data => {
aplicarMudancasRemotas(data);
tentativas = 0;
setTimeout(sincronizarPull, 30000); // 30s
})
.catch(() => {
tentativas++;
const delay = Math.min(1000 * Math.pow(2, tentativas), 60000);
setTimeout(sincronizarPull, delay);
});
}
Sincronização Push
WebSockets ou Server-Sent Events (SSE) entregam mudanças em tempo real.
// Cliente WebSocket para sincronização push
const ws = new WebSocket('wss://meuservidor.com/sync');
ws.onmessage = (event) => {
const mudancas = JSON.parse(event.data);
aplicarMudancasRemotas(mudancas);
};
Sincronização Incremental
Delta sync transmite apenas as diferenças, usando formatos como JSON Patch ou patches CRDT.
// Exemplo de diff usando JSON Patch
const patch = [
{ op: 'replace', path: '/tarefas/task-001/titulo', value: 'Comprar fertilizantes' },
{ op: 'add', path: '/tarefas/task-002', value: { id: 'task-002', titulo: 'Irrigar' } }
];
// Enviar patch para servidor
fetch('/api/sync/patch', {
method: 'PATCH',
body: JSON.stringify(patch)
});
5. Resolução de Conflitos e Consistência
Modelos de Consistência
- Eventual consistency: aceita que diferentes nós vejam dados diferentes temporariamente
- Strong consistency offline: impossível por definição — a consistência forte requer coordenação em tempo real
Estratégias de Resolução
- LWW (Last Writer Wins): o timestamp mais recente vence — simples, mas pode perder dados
- Merge automático com CRDTs: bibliotecas como Yjs e Automerge fazem merge inteligente de texto e estruturas
- Resolução manual: interface para o usuário escolher qual versão manter
// Exemplo com Automerge (conceitual)
import * as automerge from '@automerge/automerge';
let doc = automerge.from({ tarefas: [] });
doc = automerge.change(doc, 'Adicionar tarefa', (d) => {
d.tarefas.push({ id: 'task-003', titulo: 'Colheita', concluida: false });
});
// Ao receber mudanças remotas
const novoDoc = automerge.merge(doc, docRemoto);
6. Gerenciamento de Estado e Ciclo de Vida Offline
Detecção de Conectividade
// Detecção de estado online/offline
window.addEventListener('online', () => {
document.getElementById('status').textContent = 'Online';
processarFilaOperacoes();
});
window.addEventListener('offline', () => {
document.getElementById('status').textContent = 'Offline - operações serão sincronizadas quando reconectar';
});
// Verificação inicial
if (!navigator.onLine) {
mostrarIndicadorOffline();
}
Fila de Operações Pendentes
// Fila persistente com retry exponencial
const filaOperacoes = [];
function adicionarOperacaoFila(operacao) {
filaOperacoes.push(operacao);
salvarFilaNoIndexedDB(filaOperacoes);
}
async function processarFilaOperacoes() {
while (filaOperacoes.length > 0) {
const operacao = filaOperacoes[0];
try {
await fetch('/api/sync/operacao', {
method: 'POST',
body: JSON.stringify(operacao)
});
filaOperacoes.shift(); // Remove operação bem-sucedida
} catch (error) {
console.error('Falha ao sincronizar, tentando novamente...', error);
break; // Interrompe e tenta novamente no próximo ciclo
}
}
salvarFilaNoIndexedDB(filaOperacoes);
}
Feedback Visual
- Indicador de "salvando localmente" com ícone de nuvem
- Barra de progresso de sincronização
- Badge com número de operações pendentes
7. Ferramentas e Frameworks Práticos
Bibliotecas Especializadas
| Ferramenta | Descrição | Caso de Uso |
|---|---|---|
| PouchDB | Banco NoSQL no navegador que sincroniza com CouchDB | Aplicações com dados estruturados simples |
| RxDB | Banco reativo com suporte a CRDTs e replicação multi-mestre | Apps que precisam de observables e sincronização em tempo real |
| WatermelonDB | Banco SQLite para React Native com sincronização eficiente | Aplicações mobile offline-first |
Frameworks Reativos
- Redux Offline: extensão do Redux com fila de ações offline e sincronização automática
- Apollo Client: cache persistente GraphQL com suporte a operações offline
Service Workers com Workbox
// Workbox: estratégia Stale-While-Revalidate para API
import { registerRoute } from 'workbox-routing';
import { StaleWhileRevalidate } from 'workbox-strategies';
registerRoute(
({ url }) => url.pathname.startsWith('/api/'),
new StaleWhileRevalidate({
cacheName: 'api-cache'
})
);
8. Testes, Monitoramento e Boas Práticas
Testes de Cenários Offline
// Simulação de desconexão para testes
function simularOffline(tempoMs) {
cy.log('Simulando desconexão por ' + tempoMs + 'ms');
cy.intercept('*', (req) => {
return new Promise((resolve) => {
setTimeout(() => {
req.destroy(); // Simula falha de rede
resolve();
}, tempoMs);
});
});
}
Monitoramento
- Métricas: taxa de conflitos, tempo médio de sincronização, operações pendentes
- Logs de sincronização no servidor para depuração
- Alertas para filas de operações que não são processadas há muito tempo
Boas Práticas
- Priorização de dados críticos: sincronizar primeiro dados essenciais (autenticação, configurações)
- Compressão: usar gzip ou brotli para payloads de sincronização
- Limpeza de cache: remover dados antigos ou não utilizados periodicamente
- Privacidade: criptografar dados locais sensíveis e permitir que o usuário limpe o cache
Referências
- PouchDB - Guia de Sincronização Offline-First — Documentação oficial com tutoriais práticos sobre sincronização bidirecional com CouchDB
- CRDTs e Automerge - Artigo Técnico — Introdução aos Conflict-free Replicated Data Types e implementação com Automerge
- Workbox - Estratégias de Cache para Service Workers — Guia completo do Google para caching offline com Workbox
- RxDB - Documentação de Replicação — Tutorial sobre replicação multi-mestre e sincronização em tempo real
- MDN Web Docs - IndexedDB API — Referência completa sobre armazenamento estruturado no navegador
- Redux Offline - Documentação — Guia para gerenciamento de estado offline com Redux
- WatermelonDB - Sincronização — Documentação oficial do banco SQLite offline-first para React Native