Arquitetura Local-First para Progressive Web Apps
Os service workers movem o app para o dispositivo do usuário; o local-first move os dados. Essa única distinção explica por que o shell do seu PWA carrega instantaneamente offline, enquanto cada lista, registro e formulário ainda fica girando em um fetch() que falha no momento em que a rede cai. O shell está em cache; os dados, não. Eles residem em camadas diferentes, e a lacuna entre elas é onde a maioria do “suporte offline” de PWAs falha silenciosamente.
Este artigo é destinado a desenvolvedores que já publicaram um PWA — o manifesto é válido, o service worker faz cache dos assets, o app é instalável na tela inicial — e que suspeitam que “local-first” significa algo mais do que o trabalho de offline-first que já realizaram. E significa mesmo. Local-first é uma arquitetura de dados, não um mecanismo de runtime. O restante deste artigo separa essas camadas de forma clara, mostra como um PWA local-first se parece como uma stack que você adiciona em vez de reconstruir, apresenta as opções de armazenamento e sincronização, e oferece um guia específico para PWAs sobre quando esse padrão justifica sua complexidade.
Principais Conclusões
- Service workers fazem cache de assets e servem o shell offline; local-first é uma arquitetura de dados na qual o dispositivo mantém a cópia primária dos dados e o servidor, quando presente, atua como um par de sincronização em vez de um controlador de acesso.
- Um PWA local-first tem três camadas combináveis: um service worker (assets e shell offline), um banco de dados local (leituras e escritas de dados) e um mecanismo de sincronização (consistência eventual com o servidor) — você adiciona a segunda e a terceira sem substituir a primeira.
- O IndexedDB é o piso prático para armazenamento local de dados; o SQLite com suporte a OPFS compilado para WebAssembly é o teto atual, oferecendo um banco de dados relacional completo no navegador.
- O last-write-wins em nível de campo resolve a maioria dos conflitos em aplicações típicas; textos colaborativos precisam de CRDTs, e conflitos semânticos (dois usuários reservando a mesma sala) exigem validação no lado do servidor durante a sincronização.
- Local-first é adequado para PWAs instaláveis de anotações, serviços de campo e edição colaborativa; não agrega nada a dashboards de dados gerados pelo servidor ou a qualquer sistema vinculado a garantias ACID.
Por Que os Dados do Seu PWA Ainda Dependem da Rede
Um PWA com um shell em cache, mas com uma camada de dados dependente de fetch(), é offline apenas no sentido mais restrito — o chrome carrega sem a rede, e então cada componente que precisa de dados para. O service worker consegue reproduzir o HTML, o CSS e o JavaScript a partir da Cache API sem acessar a rede, então o app renderiza offline. Mas os dados que esses componentes exibem ainda vêm de uma requisição de rede, e um ServiceWorker interceptando um fetch() para dados dinâmicos do usuário não tem nada útil a retornar quando esses dados nunca foram armazenados em cache ou foram alterados desde então.
Esse é o motivo estrutural pelo qual o trabalho de offline-first em PWAs para no shell. Estratégias de cache — cache-first, network-first, stale-while-revalidate — são respostas à pergunta qual versão de um asset servir e quão desatualizada ela pode estar. Elas não respondem onde os dados do usuário residem e quem possui a cópia autoritativa. A Cache API armazena respostas HTTP indexadas por requisição. Ela não é um banco de dados que você consulta, escreve e lê dentro de um componente. Para fechar essa lacuna, você precisa de um armazenamento de dados local real e de uma estratégia para mantê-lo consistente com o servidor. Isso é local-first.
Local-First Não É Offline-First, e Nenhum Deles É um Service Worker
Local-first, offline-first, PWA e cache de service worker são quatro conceitos distintos que se combinam, mas não são intercambiáveis. Confundi-los é a confusão mais comum nesse espaço.
| Termo | O que é | Em qual camada opera |
|---|---|---|
| PWA | Um modelo de entrega: app web instalável, orientado a manifesto, com suporte a push | Empacotamento |
| Service worker | Um mecanismo de runtime que intercepta requisições e serve assets em cache | Rede / runtime |
| Offline-first | Um objetivo de design: degradar graciosamente quando a rede está indisponível, com o servidor ainda sendo a fonte da verdade | Comportamento |
| Local-first | Uma arquitetura de dados: o dispositivo mantém a cópia primária; o servidor é um par de sincronização | Dados |
Offline-first significa que a aplicação lida graciosamente com a perda de rede, mas o servidor permanece como a fonte da verdade — o cache local é uma cópia de conveniência que se subordina ao remoto. Local-first inverte essa relação: o dispositivo do usuário mantém a cópia primária dos dados, a aplicação lê e escreve em um banco de dados local, e o servidor, quando presente, é um nó entre vários que se reconciliam em segundo plano. A separação clara importa porque indica o que você pode reutilizar. Seu service worker e seu framework não mudam. Você está adicionando uma camada de dados abaixo deles.
O walkthrough local-first da Plainvanilla integra o backend-for-frontend ao service worker como parte da arquitetura. Essa é uma implementação possível, não a definição. Mantenha as camadas distintas: local-first é sobre onde os dados residem e qual cópia é autoritativa; o service worker é um mecanismo que, por acaso, está disponível no mesmo runtime.
Discover how at OpenReplay.com.
O Que Local-First Realmente Significa
Local-first é uma arquitetura de dados na qual o dispositivo do usuário mantém a cópia primária dos dados da aplicação, o app lê e escreve em um banco de dados local, e o servidor, quando presente, é um par de sincronização com autoridade especial em vez de um controlador de acesso que precisa aprovar cada leitura e escrita. A aplicação lê e escreve em um banco de dados local de forma síncrona da perspectiva do usuário, e a sincronização com o servidor ou outros dispositivos ocorre de forma assíncrona em segundo plano. A mudança arquitetural é uma reclassificação do cliente: ele deixa de ser uma view thin que solicita permissão para exibir dados e passa a ser um participante pleno que possui uma réplica.
A referência canônica é o ensaio de 2019 Local-First Software: You Own Your Data, in Spite of the Cloud do Ink & Switch, que apresenta sete ideais — rápido, multi-dispositivo, offline, colaborativo, duradouro, privado e controlado pelo usuário. A única propriedade que muda a forma como você escreve código é a primeira: como leituras e escritas vão para um banco de dados local, não há flag isLoading em uma leitura, e escritas podem atualizar o estado local imediatamente. A escrita local é o estado, enquanto a sincronização e a resolução de conflitos ocorrem em segundo plano.
No código de componentes, isso colapsa uma dança de fetch-e-cache em uma consulta direta. Em vez de um useQuery que retorna { data, isLoading, error }, uma consulta local-first se inscreve no banco de dados local e re-renderiza quando ele muda:
// Local-first: the query reads the local DB and re-renders on change.
// No isLoading, no error boundary for the read, no cache invalidation.
function TaskList({ projectId }) {
const tasks = useLiveTasks(projectId); // subscribes to local DB
return (
<ul>
{tasks.map((t) => (
<li key={t.id}>{t.title}</li>
))}
</ul>
);
}
O hook useLiveTasks é fornecido pelo mecanismo de sincronização ou pela camada de consulta que você escolher; o formato é o que importa. Uma escrita é uma inserção simples no armazenamento local, a inscrição dispara e a UI é atualizada. A sincronização com o servidor ocorre sempre que a rede permitir.
As Três Camadas de um PWA Local-First
Um PWA local-first tem três camadas combináveis: um service worker que faz cache de assets e serve o shell offline, um banco de dados local (IndexedDB ou SQLite com suporte a OPFS) que mantém os dados do usuário e serve todas as leituras e escritas, e um mecanismo de sincronização que reconcilia o banco de dados local com o servidor em segundo plano. Cada camada possui uma responsabilidade e não conhece os detalhes internos das demais.
| Camada | Função | Responsável por | NÃO é responsável por |
|---|---|---|---|
| Camada 3 — Mecanismo de sincronização | Reconcilia o banco de dados local com o servidor. | Resolução de conflitos, consistência eventual, push/pull em segundo plano. | Renderização, cache de assets. |
| Camada 2 — Banco de dados local | Mantém os dados do usuário. IndexedDB ou SQLite com suporte a OPFS (WASM). | Todas as leituras e escritas que a UI realiza. | A rede, o shell offline. |
| Camada 1 — Service worker | Faz cache de assets e serve o shell offline. | Cache de assets, serviço do shell offline, ciclo de vida de install/activate, push. | Dados do usuário. |
Adotar local-first em um PWA existente não exige substituir o service worker ou o framework da aplicação — exige adicionar uma camada de dados da qual a aplicação lê e escreve localmente, e um mecanismo de sincronização que mantém essa camada consistente com o servidor. A Camada 1 já existe no seu PWA. Você está introduzindo as Camadas 2 e 3 abaixo dos componentes que atualmente chamam fetch(), e reconfigurando esses componentes para ler do banco de dados local.
Onde os Dados Residem: Um Guia Prático de Armazenamento Local
localStorage não é a resposta. É síncrono, portanto bloqueia a thread principal; armazena apenas strings; e, conforme a documentação da Web Storage API do MDN, sua capacidade é pequena e específica por navegador — adequado para uma preferência de tema, inadequado para uma camada de dados.
O IndexedDB é o piso — assíncrono, disponível em todos os navegadores modernos e capaz de armazenar muito mais do que o localStorage, embora a cota de armazenamento seja baseada na origem e específica por navegador em vez de um limite fixo. Sua API nativa é de baixo nível e verbosa — uma única escrita requer abrir uma transação, apontar para um object store e configurar callbacks de requisição — razão pela qual a maioria das aplicações recorre a uma biblioteca wrapper.
O teto é o SQLite compilado para WebAssembly, persistido no Origin Private File System (OPFS). O SQLite com suporte a OPFS oferece um banco de dados relacional completo no navegador — transações, índices e uma interface SQL familiar rodando localmente no cliente. O OPFS suporta acesso síncrono de alto desempenho a arquivos por meio do createSyncAccessHandle(), disponível apenas dentro de um Web Worker — exatamente o padrão de acesso que o SQLite necessita. A distribuição oficial do SQLite para WebAssembly documenta o OPFS como um backend de persistência suportado.
No código, uma escrita contra SQLite-over-WASM se parece com SQL do lado do servidor — uma única instrução contra um armazenamento relacional:
// SQLite (WASM) via the official sqlite-wasm package: ordinary SQL.
await db.exec({
sql: "INSERT INTO tasks (id, title, done) VALUES (?, ?, 0)",
bind: [crypto.randomUUID(), "Review draft"],
});
O suporte dos navegadores ao OPFS é amplo no final de 2025 — consulte a tabela de compatibilidade do MDN para a File System API para o status atual por navegador, que é a referência a verificar em relação à sua matriz de suporte em vez de uma lista de versões fixas, pois ela muda. Um problema comum em produção é que o comportamento do OPFS pode diferir sutilmente entre engines de navegadores e contextos de incorporação, portanto, manter um caminho de fallback com IndexedDB continua valendo a pena para navegadores e ambientes onde o suporte ao OPFS está indisponível, limitado ou se comporta de forma diferente da sua plataforma-alvo principal.
O Panorama dos Mecanismos de Sincronização
O mecanismo de sincronização é a camada que torna o local-first algo além do offline-first: ele reconcilia a réplica local com o servidor e outros dispositivos. Você não precisa construí-lo. Vários mecanismos — tanto em produção quanto emergentes — ocupam nichos diferentes, e o mais adequado depende da forma do seu app e do seu backend existente. Não há um padrão web para sincronização, então cada mecanismo define seu próprio protocolo — mantenha a camada de sincronização abstraída para que a troca de mecanismos seja viável.
- PowerSync — replica um backend Postgres para um banco de dados SQLite no cliente com um caminho de write-back; a escolha ideal quando você já usa Postgres e quer suporte offline sem rearquitetar o servidor.
- Electric — um mecanismo de sincronização para Postgres construído em torno de “Shapes” que definem quais dados cada cliente recebe. Ideal para aplicações que precisam de replicação parcial e sincronização de dados por usuário sobre um backend Postgres existente.
- Replicache e Zero (ambos da Rocicorp) — sistemas de sincronização orientados a consultas que priorizam experiências de usuário local-first em vez de replicação em nível de linha. Ideal para aplicações construídas em torno de mutações no cliente, atualizações otimistas e reconciliação no lado do servidor.
- Triplit — um banco de dados full-stack com sincronização integrada, de modo que o banco de dados do cliente e do servidor formam um único modelo mental em vez de dois; adequado para apps greenfield que querem sincronização como padrão.
- Yjs e Automerge — bibliotecas CRDT para edição colaborativa; a escolha certa quando múltiplos usuários editam o mesmo texto rico ou documento estruturado de forma concorrente e você precisa de merge em nível de caractere.
Para a maioria dos PWAs de linha de negócios que sincronizam registros de propriedade do usuário, um mecanismo de replicação de linhas sobre seu banco de dados existente é uma escolha mais adequada do que uma biblioteca CRDT. Recorra ao Yjs ou ao Automerge especificamente quando a edição colaborativa de texto em tempo real é o produto em si, não como uma solução geral para conflitos.
Resolvendo Conflitos
Quando duas réplicas modificam os mesmos dados sem ver as alterações uma da outra, o mecanismo de sincronização precisa reconciliá-las. Os conflitos se enquadram em três categorias, cada uma com uma estratégia de resolução diferente.
-
Conflitos estruturais — last-write-wins em nível de campo. O last-write-wins em nível de campo lida com a maioria dos conflitos em aplicações típicas: se dois usuários editam campos diferentes do mesmo registro offline, ambas as alterações sobrevivem; se editam o mesmo campo, o timestamp mais recente vence. Essa é uma regra amplamente usada na comunidade local-first, não uma constante medida — o livro Designing Data-Intensive Applications de Martin Kleppmann aborda em profundidade os trade-offs de consistência do last-write-wins.
-
Conflitos em texto colaborativo — CRDTs. Quando dois usuários digitam no mesmo parágrafo, o last-write-wins descarta os toques de teclado de uma pessoa. Os conflict-free replicated data types mesclam edições concorrentes em nível de caractere para que ambos os conjuntos de caracteres apareçam de forma coerente. Yjs e Automerge implementam isso; a mecânica de merge difere, mas a garantia é a mesma — edições concorrentes convergem sem um coordenador central.
-
Conflitos semânticos — validação no lado do servidor. Alguns conflitos se mesclam de forma limpa no nível estrutural, mas produzem um resultado que viola uma invariante de domínio. Exemplo: dois usuários offline reservam a mesma sala de reunião para as 14h. Ambas as escritas apontam para registros diferentes, então um merge em nível de campo aceita as duas — estruturalmente correto, mas uma reserva duplicada. Conflitos semânticos, nos quais os dados se mesclam de forma limpa, mas o resultado viola uma invariante de domínio, exigem validação no lado do servidor durante a sincronização. O padrão que evita perda de dados é aceitar a escrita conflitante, sinalizar a violação e apresentá-la ao usuário como uma notificação resolvível, em vez de rejeitá-la silenciosamente — a rejeição deixa o cliente com registros que o servidor se recusa a reconhecer.
Os bugs mais difíceis em PWAs local-first não são falhas de sincronização — essas aparecem nos logs. São sobrescritas silenciosas: uma escrita otimista é bem-sucedida localmente, a sincronização é concluída sem erro, e a alteração do usuário é silenciosamente substituída por uma versão remota que venceu a corrida de last-write-wins. A falha é invisível para os logs do servidor, para o monitoramento de erros e para os rastreamentos de rede; ela só se torna legível em uma ferramenta que registra a sequência de estados da UI — a atualização otimista e a reversão silenciosa que se segue. O session replay é um dos poucos lugares onde essa discrepância aparece como uma sequência observável em vez de uma linha de log limpa.
Quando Local-First Se Encaixa no Seu PWA Especificamente
Local-first se encaixa em um PWA quando os dados que o app gerencia são de propriedade do usuário e se beneficiam de interação local instantânea; não agrega nada quando os dados são gerados pelo servidor ou vinculados a fortes garantias transacionais. A pergunta decisiva não é a categoria do app em abstrato — é se os dados pertencem ao dispositivo.
| Caso de uso do PWA | Local-first ajuda? | Por quê |
|---|---|---|
| App instalável de anotações | Sim | Dados de propriedade do usuário, leituras/escritas instantâneas, edição offline é o valor central; o shell instalável e os dados locais se reforçam mutuamente. |
| App de serviço de campo com conectividade instável | Sim | O trabalho acontece onde a rede é fraca; as escritas precisam ser bem-sucedidas offline e sincronizar quando a cobertura retornar. |
| Ferramenta de edição colaborativa | Sim | A edição concorrente é o produto; a sincronização baseada em CRDT entrega merge em tempo real sem um roundtrip por tecla pressionada. |
| Dashboard de analytics | Não | O servidor gera os dados; fazer cache do shell é útil, mas local-first não agrega nada a dados que o usuário não possui nem modifica. |
| Checkout de e-commerce | Não | Pagamento e estoque precisam de garantias ACID e um único banco de dados autoritativo; a consistência eventual pode vender mais estoque do que o disponível ou cobrar em duplicidade. |
| Feed social | Não | O feed é ranqueado e de propriedade do servidor; o cliente o consome em vez de criá-lo. |
O padrão se alinha com os pontos fortes do PWA exatamente onde o usuário cria e possui os dados. Para um PWA de anotações, o shell instalável, a escrita offline e a sincronização em segundo plano se combinam em uma experiência próxima a um app nativo. Para um dashboard, o service worker pode fazer cache do shell para um carregamento mais rápido, mas os números ainda vêm do servidor — local-first resolve um problema que esse caso de uso não tem.
Você Adiciona uma Camada de Dados, Não Reconstrói o App
Adaptar o local-first em um PWA existente significa adicionar duas camadas, não reconstruir o que você tem: um banco de dados local e um slot de mecanismo de sincronização abaixo dos componentes que atualmente ficam esperando no fetch(), enquanto o service worker e o framework permanecem onde estão. Você não está substituindo o trabalho de PWA que já fez — está completando-o, movendo os dados para o usuário da mesma forma que o service worker já moveu o app. O próximo passo concreto é pequeno: escolha uma funcionalidade cujos dados o usuário possui e edita, suporte-a com IndexedDB ou SQLite com suporte a OPFS, configure seu componente para ler desse armazenamento e deixe um mecanismo de sincronização reconciliá-lo em segundo plano. Essa única funcionalidade vai te dizer, mais rápido do que qualquer leitura adicional, se o restante do seu app pertence lá também.
Perguntas Frequentes
Geralmente sim, mas seu papel muda. Em uma arquitetura local-first, o servidor deixa de ser um controlador de acesso que aprova cada leitura e escrita e passa a ser um par de sincronização que reconcilia o banco de dados local entre dispositivos, persiste uma cópia durável e aplica validação no lado do servidor para conflitos semânticos, como reservas duplicadas. Apps que precisam de garantias ACID, pagamentos ou estado compartilhado autoritativo ainda requerem um backend real; apenas o caminho de leitura e escrita pela UI se move para o banco de dados local.
Não. Eles operam em camadas diferentes e se combinam em vez de competir. O service worker faz cache de assets e serve o shell offline; local-first adiciona um banco de dados local e um mecanismo de sincronização abaixo dos componentes que atualmente chamam fetch. Adaptar o local-first em um PWA existente significa manter o service worker e o framework no lugar e introduzir as camadas de dados e sincronização abaixo deles, não reescrever a Camada 1.
Escolha CRDTs quando a edição concorrente do mesmo texto rico ou documento estruturado é o produto em si, porque o last-write-wins descarta os toques de teclado de um usuário quando duas pessoas digitam no mesmo parágrafo. Os conflict-free replicated data types mesclam edições concorrentes em nível de caractere para que ambos os conjuntos de caracteres convirjam sem um coordenador central. Para a maioria dos PWAs de linha de negócios que sincronizam registros de propriedade do usuário, o last-write-wins em nível de campo sobre um mecanismo de replicação de linhas é a escolha mais adequada; reserve Yjs ou Automerge para texto colaborativo em tempo real.
Isso é uma sobrescrita silenciosa. Uma escrita otimista é bem-sucedida localmente, a sincronização é concluída sem erro, e uma versão remota vence a corrida de last-write-wins, substituindo silenciosamente a alteração local. A falha é invisível para os logs do servidor, que mostram sucesso, para o monitoramento de erros, que não lança nenhuma exceção, e para os rastreamentos de rede, que mostram que a requisição passou. Ela só se torna legível em uma ferramenta que registra a sequência de estados da UI, capturando tanto a atualização otimista quanto a reversão que se segue.
Understand every bug
Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data. Check our GitHub repo and join the thousands of developers in our community.