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

  1. LWW (Last Writer Wins): o timestamp mais recente vence — simples, mas pode perder dados
  2. Merge automático com CRDTs: bibliotecas como Yjs e Automerge fazem merge inteligente de texto e estruturas
  3. 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

  1. Priorização de dados críticos: sincronizar primeiro dados essenciais (autenticação, configurações)
  2. Compressão: usar gzip ou brotli para payloads de sincronização
  3. Limpeza de cache: remover dados antigos ou não utilizados periodicamente
  4. Privacidade: criptografar dados locais sensíveis e permitir que o usuário limpe o cache

Referências