Apostila completa de Local-First & Apps Colaborativos em Tempo Real
Local-first inverte a relação com a nuvem: os dados vivem no dispositivo, as operações são locais e instantâneas, e a sincronização acontece em background — sem que o app pare quando a rede cai. Esta apostila cobre os ideais e o modelo, a resolução de conflito, os CRDTs a fundo, as bibliotecas e os "sync engines", a persistência e o transporte no cliente, a colaboração em tempo real (presence, rich text, undo colaborativo), o sync com um backend autoritativo, e o que muda em produção.
O que é local-first
Objetivo: entender os ideais do local-first, o problema que ele resolve, como se diferencia de offline-first e local-only, e quando (não) vale a pena.
1.1 O problema com o "cloud-first"
Na arquitetura padrão (SPA + API + banco na nuvem), o servidor é o dono dos dados: toda leitura e escrita passa por ele. Consequências:
- Sem rede, o app não funciona (ou funciona pela metade).
- Latência: cada ação espera um round-trip.
- Você não controla seus dados: dependem da empresa continuar existindo e mantendo o serviço.
- Colaboração exige o servidor como árbitro central.
- Privacidade: tudo passa (e costuma ficar) no servidor.
1.2 Os ideais do local-first
O ensaio Local-first software (Ink & Switch, 2019) propõe sete propriedades desejáveis:
- Rápido — sem esperar a rede para nada.
- Multi-dispositivo — seus dados em todos os seus aparelhos.
- Offline — funciona sem conexão, sincroniza depois.
- Colaboração — várias pessoas editando, com merge automático.
- Longevidade — os dados continuam acessíveis "para sempre", mesmo se o serviço morrer.
- Privacidade e segurança — por padrão, e com E2EE possível.
- Controle do usuário — você é o dono; pode exportar, mover, usar outro cliente.
1.3 O espectro
| Abordagem | Fonte da verdade | Offline | Colaboração |
|---|---|---|---|
| Cloud-first | servidor | não (ou cache read-only) | via servidor |
| Offline-first | servidor, com cache de escrita e fila de sync | parcial; conflitos tratados na volta | possível, mais frágil |
| Local-first | o dispositivo; o servidor é relay/backup | total | nativa, merge automático (CRDT) |
| Local-only | o dispositivo, sem sync | total | não |
1.4 Quando vale — e quando não
- Vale: ferramentas de produtividade (notas, tarefas, docs, design, código), apps de campo/uso offline, apps onde a latência e a soberania de dados são diferencial, produtos que querem "seus dados são seus".
- Não vale (ou é caro demais): apps que são a nuvem (feed social global, marketplace, banco), dados que precisam de uma única verdade autoritativa e transações fortes (estoque, pagamentos), quando o cliente não pode ter os dados (privacidade entre usuários, volume gigante), ou quando um CRUD simples resolve e o time é pequeno.
- Muitos produtos adotam local-first só para uma parte (o editor, o board) e mantêm o resto cloud-first.
Local-first é uma decisão de arquitetura de dados, não uma biblioteca. O cerne é: o cliente pode ler e escrever sem a rede, e a convergência entre cópias é automática. Tudo o mais — CRDT vs OT, qual sync engine, P2P ou servidor, E2EE ou não — decorre de quão longe você quer levar cada um dos sete ideais.
Perguntas de abertura: "O que é local-first e o que ele resolve?" (dados no dispositivo, ler/escrever sem rede, merge automático; resolve latência, offline, controle, colaboração), "Diferença entre offline-first e local-first?" (offline-first: servidor ainda é o dono, com cache e fila; local-first: o dispositivo é a fonte), "Quando você não usaria local-first?" (dados que precisam de verdade única/transações fortes, apps que são a nuvem, o cliente não pode ter os dados).
✏️ Exercício 1 — Classifique e decida
Para cada produto, diga se local-first faz sentido (todo, em parte, ou não), e por quê: (a) um app de notas pessoais; (b) um editor de documentos colaborativo estilo Notion; (c) um sistema de reservas de voo; (d) um app de lista de compras compartilhada com a família; (e) o painel de administração de um e-commerce.
Gabarito (uma boa resposta): (a) total — dados pessoais, offline importa, sem colaboração complexa; caso ideal. (b) total no editor (CRDT de rich text + presence) + servidor para permissões, busca, compartilhamento; o "core" é local-first, o entorno é híbrido. (c) não — assento é recurso escasso com verdade única; precisa de transação forte no servidor; local-first geraria overbooking. (d) total — pequeno, colaborativo, offline no mercado é o cenário; um CRDT set/list resolve. (e) não (ou mínimo) — dados de negócio autoritativos, agregações, relatórios, controle de acesso fino; cloud-first com talvez um cache offline read-only.
O modelo: dados no dispositivo
Objetivo: entender o fluxo — cópia local como fonte, leituras/escritas instantâneas, sync em background, o servidor como relay — e por que o conflito é inevitável.
2.1 O fluxo
UI ⇄ estado local (a fonte da verdade para este cliente)
│
├─ persiste no dispositivo (IndexedDB / SQLite-WASM / OPFS)
│
└─ sync engine ⇄ rede (WebSocket / WebRTC) ⇄ outros clientes / servidor-relay
- Leitura: direto do estado local — instantânea, funciona offline.
- Escrita: aplicada ao estado local já (UI otimista de graça), e enfileirada para sync.
- Sync: em background, contínuo quando há rede; ao reconectar, troca o que faltou.
- O servidor (quando há um) recebe e redistribui as mudanças, guarda um backup, e — no modelo híbrido (Módulo 8) — valida e é a base para dados derivados. Ele não é o gargalo de cada ação.
2.2 Cópia completa ou parcial
- Cópia completa: cada cliente tem todo o documento/dataset. Simples; funciona para dados pequenos/médios (um doc, um board, as notas de uma pessoa).
- Replicação parcial (partial replication / "shapes"): o cliente tem só o subconjunto relevante (este projeto, estas 500 linhas). Necessário quando o dataset total é grande. Os sync engines modernos giram em torno disso (Módulo 5, 8).
2.3 Por que o conflito é inevitável
Se dois clientes podem escrever sem coordenação prévia (é o ponto do local-first), então dois usuários — ou o mesmo usuário em dois aparelhos — vão, cedo ou tarde, editar "a mesma coisa" enquanto separados. Quando reconectam, há duas histórias divergentes. O sistema precisa de uma regra determinística para reconciliá-las de forma que todos os clientes cheguem ao mesmo resultado (convergência) — é o Módulo 3.
Perguntas: "Como uma escrita funciona num app local-first?" (aplica no estado local na hora — UI otimista nativa — e enfileira para sync), "O servidor deixa de existir?" (não; vira relay/backup e, no híbrido, valida e hospeda dados derivados — mas não é o caminho de cada ação), "Cópia completa ou parcial?" (completa para dados pequenos; replicação parcial via shapes para datasets grandes), "Por que conflito é garantido?" (escrita sem coordenação → histórias divergentes → precisa de reconciliação determinística).
✏️ Exercício 2 — Desenhe o fluxo
Descreva o fluxo de dados de um app de tarefas local-first quando: (a) o usuário marca uma tarefa como concluída offline; (b) ele fica 3 dias offline e nesse tempo um colega adiciona 5 tarefas e edita o título de uma que o usuário também editou; (c) o usuário volta a ficar online.
Gabarito (uma boa resposta): (a) o clique aplica done=true ao estado local na hora; a UI atualiza; a operação (com um timestamp lógico / ID) é persistida no dispositivo e colocada na fila de sync, que não envia porque não há rede. (b) enquanto isso, o colega gera 5 operações de "add" e 1 de "edit title" no doc dele, que sincronizam com o servidor-relay e com os outros clientes online. (c) ao reconectar, o cliente do usuário envia suas operações pendentes (o "done" e o "edit title" dele) e baixa as que faltavam (as 5 adds + o "edit title" do colega). O CRDT faz o merge: as 5 tarefas novas aparecem; o "done" aplica; para o título editado por ambos, a regra do CRDT decide (ex.: LWW pelo timestamp lógico maior) — determinística, então o resultado é o mesmo em todos os clientes. Idealmente a UI sinaliza "este título foi alterado por 2 pessoas" se o produto quiser expor isso.
Resolução de conflito
Objetivo: comparar last-write-wins, merge manual, Operational Transform e CRDTs; entender convergência; e a diferença entre consistência técnica e intenção do usuário.
3.1 As estratégias
| Estratégia | Como | Trade-off |
|---|---|---|
| Last-write-wins (LWW) | a escrita com timestamp maior vence; a outra é descartada | trivial de implementar; perde dados silenciosamente; ruim para texto e listas |
| Merge manual (Git-style) | detecta conflito e pede ao humano resolver | não perde nada; interrompe o fluxo; inaceitável em edição ao vivo |
| Operational Transform (OT) | transforma cada operação contra as concorrentes para que apliquem na ordem certa | usado por Google Docs; difícil de implementar corretamente; costuma exigir um servidor central que serializa |
| CRDT (Conflict-free Replicated Data Type) | estruturas de dados que por construção convergem, sem coordenação | o caminho local-first; peer-to-peer possível; custo de metadados; a base das libs modernas (Módulo 4) |
3.2 Convergência
A propriedade que importa: dadas as mesmas operações (em qualquer ordem, com repetições), todos os clientes chegam ao mesmo estado final. CRDTs garantem isso porque as operações de merge são comutativas (a ordem não importa), associativas (o agrupamento não importa) e idempotentes (aplicar duas vezes = uma vez). OT garante por transformação + ordenação. LWW garante de forma pobre (converge, mas descartando).
3.3 Convergência ≠ intenção
- Convergir significa "não quebra e todos veem o mesmo" — não significa "o resultado é o que os usuários queriam".
- Exemplo clássico: dois usuários inserem itens diferentes na mesma posição de uma lista — o CRDT ordena deterministicamente (por ID), então ninguém perde o item, mas a ordem pode não ser a "esperada" por nenhum dos dois.
- Em texto, dois editam a mesma frase: o merge dá uma frase válida e igual para todos, mas possivelmente sem sentido. A resposta de produto: presence (Módulo 7) para as pessoas verem onde as outras estão editando e evitarem colidir, e granularidade fina de operações.
- Para dados estruturados críticos, às vezes se escolhe expor o conflito ("2 valores concorrentes, escolha") em vez de deixar o CRDT decidir sozinho.
Perguntas: "Compare LWW, OT e CRDT" (LWW: simples, perde dados; OT: Google Docs, difícil, costuma exigir servidor serializador; CRDT: converge por construção, P2P possível, custo de metadados), "O que é convergência?" (mesmas operações → mesmo estado final; via merge comutativo/associativo/idempotente), "Convergir garante o resultado que o usuário queria?" (não — garante consistência; a intenção se protege com presence e granularidade, ou expondo o conflito).
✏️ Exercício 3 — Escolha a estratégia
Para cada dado num app de projeto, escolha a estratégia de conflito (LWW, CRDT específico, expor conflito) e justifique: (a) o título de um cartão; (b) a descrição em rich text; (c) a lista ordenada de cartões de uma coluna; (d) a lista de responsáveis (um set); (e) o status (enum: todo/doing/done); (f) um contador de "votos".
Gabarito (uma boa resposta): (a) título: LWW register — texto curto, editar junto é raro; last-write é aceitável (talvez sinalizar). (b) descrição: CRDT de sequência/rich text (RGA-like, tipo Yjs/Automerge Text) — edição concorrente é o caso de uso. (c) ordem dos cartões: CRDT de lista/sequência com posições fracionárias ou identificadores estáveis — inserções concorrentes convergem sem perder cartão. (d) responsáveis: OR-Set (observed-remove set) — adds e removes concorrentes se resolvem sem "ressuscitar" nem "sumir" itens indevidamente. (e) status: LWW register pelo timestamp lógico — é um valor único; ou, se importa, expor "2 pessoas mudaram o status". (f) votos: CRDT counter (PN-Counter) — incrementos concorrentes somam corretamente; LWW perderia votos.
CRDTs a fundo
Objetivo: o conceito formal, state-based vs op-based, os tipos (counter, register, set, sequence, map, tree), e o custo (metadados, tombstones, GC).
4.1 O conceito
Um CRDT é uma estrutura de dados replicada em que a operação de merge forma um semilattice: é comutativa, associativa e idempotente. Isso garante Strong Eventual Consistency: réplicas que receberam o mesmo conjunto de atualizações têm o mesmo estado, sem precisar de consenso, sem rollback, sem coordenação.
4.2 State-based (CvRDT) × op-based (CmRDT)
- State-based: cada réplica manda seu estado inteiro (ou um delta); o merge é uma função de "join" (ex.: máximo elemento a elemento). Robusto a rede não confiável (idempotente, ordem livre), mas pode ser pesado — mitigado por delta-CRDTs (mandar só o que mudou).
- Op-based: cada réplica transmite as operações; exige entrega confiável e exactly-once (ou dedupe por ID) mas não necessariamente ordenada. Mais econômico; é o modelo das libs de texto.
- Na prática, Yjs e Automerge são op-based com otimizações; a maioria dos sistemas usa um híbrido com deltas.
4.3 O zoológico de tipos
| Tipo | Para | Notas |
|---|---|---|
| G-Counter / PN-Counter | contadores (só incrementa / incrementa e decrementa) | cada réplica tem seu sub-contador; o valor é a soma |
| LWW-Register / MV-Register | um valor único (LWW) ou "vários valores concorrentes" (multi-value) | LWW usa timestamp lógico (Lamport/híbrido); MV expõe o conflito |
| G-Set / 2P-Set / OR-Set | conjuntos | OR-Set (observed-remove) é o prático: add/remove concorrentes se comportam bem |
| Sequence / List / Text (RGA, Logoot, Yata, Fugue) | listas ordenadas e texto | o problema mais difícil — cada caractere/item ganha um ID posicional imutável; algoritmos diferem em "interleaving" e tamanho de metadados |
| Map | chave→valor, com valores que são outros CRDTs | compõe os anteriores; a base de "documentos" (Automerge, Yjs) |
| Tree / Move | árvores (outline, sistema de arquivos), mover subárvores | "mover" concorrente é sutil (evitar ciclos); há algoritmos dedicados |
4.4 O custo
- Metadados: cada elemento carrega ID (réplica + contador), o que infla o tamanho — pior em texto (por caractere). Libs modernas comprimem (run-length, colunar).
- Tombstones: itens deletados não somem de imediato — ficam marcados para o merge funcionar. Acumulam.
- Garbage collection: remover tombstones e comprimir histórico com segurança exige saber que todas as réplicas já viram aquilo — difícil num sistema aberto; na prática usa-se snapshots + janelas.
- Crescimento monotônico: sem GC, o documento só cresce. Estratégias no Módulo 9.
- Interleaving de texto: alguns algoritmos, sob concorrência, embaralham caracteres de inserções distintas ("heeullloo"); algoritmos novos (Fugue, e as escolhas de Yjs/Diamond Types) minimizam isso.
Você quase nunca implementa um CRDT do zero — usa Yjs, Automerge, Loro ou Diamond Types. O valor de entender o interno é escolher e diagnosticar: por que o documento incha (tombstones, sem GC), por que o merge de texto ficou estranho (interleaving), por que a memória sobe (state-based sem delta), e o que a lib garante sobre "mover" e árvores. Leia CRDT.tech e os papers de Kleppmann/Shapiro para o modelo formal.
Perguntas: "O que torna um CRDT 'conflict-free'?" (merge comutativo, associativo, idempotente → strong eventual consistency, sem consenso), "State-based vs op-based?" (manda o estado/delta vs manda as operações; trade-off de robustez × tamanho; delta-CRDTs no meio), "Qual o custo de um CRDT?" (metadados por elemento, tombstones, GC difícil, crescimento monotônico, interleaving de texto), "Por que texto é o caso difícil?".
✏️ Exercício 4 — Diagnostique
Um app de notas colaborativas com CRDT de texto está com: (a) documentos de 2 páginas ocupando 4 MB; (b) memória do cliente subindo ao longo da sessão; (c) às vezes, ao dois usuários digitarem no mesmo ponto, sai uma palavra com letras embaralhadas. Explique a causa provável de cada e a mitigação.
Gabarito (uma boa resposta): (a) metadados + tombstones sem GC: cada caractere já digitado e cada caractere apagado carrega ID e fica no histórico; 2 páginas com muita edição/apagamento acumulam. Mitigação: usar uma lib com compressão eficiente (Yjs/Loro), fazer snapshots periódicos e descartar o histórico antigo quando seguro, "arquivar" versões antigas. (b) histórico/updates acumulando em memória ou state-based sem delta; aplicar compaction e trabalhar com o documento carregado + updates incrementais, liberando o que já foi integrado. (c) interleaving do algoritmo de sequência sob inserção concorrente no mesmo ponto. Mitigação: usar um algoritmo que minimiza interleaving (Yjs/YATA, Fugue, Diamond Types) e, no produto, presence mostrando o cursor do outro para as pessoas não digitarem exatamente no mesmo lugar.
Bibliotecas e sync engines
Objetivo: conhecer Yjs e Automerge, o ecossistema de "sync engines" e de "colaboração como serviço", e o trade-off entre montar você mesmo e comprar.
5.1 As libs de CRDT
- Yjs: o CRDT mais usado na web. Tipos compartilhados (
Y.Text,Y.Array,Y.Map,Y.XmlFragment), muito otimizado, ecossistema enorme de providers (y-websocket,y-webrtc,y-indexeddb) e bindings de editor (ProseMirror, TipTap, Lexical, CodeMirror, Slate, Monaco). Traz awareness (presence) embutido. - Automerge: CRDT em Rust (via WASM) com API de "documento JSON" e histórico rico (quem mudou o quê, quando); automerge-repo cuida de sync/armazenamento/rede. Bom quando o histórico e a auditabilidade importam.
- Loro, Diamond Types, cola: gerações mais novas, focadas em performance e menos interleaving.
5.2 Os "sync engines"
Ferramentas que resolvem o sync como problema de plataforma — geralmente ligando o cliente a um Postgres (ou outro banco) com replicação parcial e reatividade:
- ElectricSQL — sync de "shapes" (subconjuntos consultáveis) de Postgres para o cliente, via HTTP, com o servidor como fonte.
- Zero (Rocicorp) — sync + queries reativas com cache local, "the sync engine for the web"; sucessor conceitual do Replicache.
- PowerSync — sync bidirecional Postgres/Mongo ↔ SQLite no cliente, forte em mobile.
- Triplit, Jazz, Instant, LiveStore, Convex — bancos/backends com sync e offline embutidos, cada um com um modelo (relacional, "covalue", triplas, event-sourcing).
- Replicache — biblioteca cliente de sync com "mutators" e reconciliação; você traz o backend.
5.3 Colaboração como serviço
- Liveblocks — presence, comentários, e storage colaborativo (com Yjs por baixo) como SaaS; rápido de adotar para "adicionar multiplayer".
- Partykit / Cloudflare Durable Objects — primitiva de "um servidor por documento/sala" no edge, ótima base para hospedar Yjs.
5.4 Montar × comprar
| Caminho | Quando |
|---|---|
Yjs/Automerge + você hospeda (Partykit, Durable Objects, um y-websocket) | você quer controle, o caso é sobretudo "um documento colaborativo", e o time aguenta operar o sync |
| Sync engine (Electric, Zero, PowerSync…) | o app é "dados relacionais reativos com offline", você já tem/quer Postgres, e quer que sync + replicação parcial + auth venham resolvidos |
| Colab-as-a-service (Liveblocks, Convex) | time-to-market, "só quero multiplayer e comentários", pouca vontade de operar infra de sync |
Perguntas: "O que é o Yjs e o que ele te dá?" (CRDT web mais usado; tipos compartilhados, providers de rede/persistência, bindings de editor, awareness/presence), "O que é um 'sync engine' e como difere de usar um CRDT direto?" (resolve sync como plataforma, geralmente ligando o cliente a Postgres com replicação parcial e reatividade; menos "documento", mais "dados relacionais reativos"), "Montar com Yjs ou usar Liveblocks/Convex?" (controle e caso "documento" vs time-to-market e não operar infra).
✏️ Exercício 5 — Escolha a stack
Para cada produto, recomende a stack de sync e justifique: (a) um editor de texto colaborativo tipo Google Docs; (b) um app de CRM offline-capaz para vendedores em campo (muitos registros, cada vendedor vê só a sua carteira); (c) um app de quadro branco (whiteboard) multiplayer; (d) um app de tarefas pessoal com sync entre os dispositivos do próprio usuário e "compartilhar uma lista" ocasional.
Gabarito (uma boa resposta): (a) Yjs + binding ProseMirror/TipTap + hospedagem por documento (Partykit / Durable Objects / y-websocket) + y-indexeddb para offline + awareness para cursores. O caso é "um documento", que é o forte do Yjs. (b) sync engine relacional (PowerSync ou ElectricSQL) com Postgres no servidor e SQLite no dispositivo, replicação parcial por carteira (shape), auth e validação no servidor. É "dados relacionais reativos com offline", não um documento. (c) Yjs (Y.Map/Y.Array de formas) + presence forte (cursores, seleção) + hospedagem por sala; ou Liveblocks se quiser acelerar. (d) um CRDT simples (Automerge ou Yjs) com automerge-repo/provider, persistência local, e um relay leve; multi-device do mesmo usuário é o caso principal, compartilhamento é a exceção — não precisa de sync engine relacional.
Persistência e transporte no cliente
Objetivo: onde e como guardar o estado no dispositivo (IndexedDB, OPFS, SQLite-WASM), e como transportar as mudanças (WebSocket, WebRTC, entre abas).
6.1 Persistência no navegador
| Opção | Bom para | Notas |
|---|---|---|
| IndexedDB | o padrão para guardar o binário do CRDT + snapshots + updates | assíncrono, transacional, cotas generosas; API verbosa (use um wrapper: idb, Dexie) |
| OPFS (Origin Private File System) | arquivos grandes, acesso rápido, base para SQLite no navegador | acesso síncrono via Worker; sem prompt ao usuário |
| SQLite-WASM (wa-sqlite, sql.js, official) | quando você quer consultas SQL reativas no cliente (a base dos sync engines relacionais) | roda sobre OPFS; permite queries locais ricas |
| Cache Storage | assets, modelos, snapshots imutáveis | via Service Worker |
| evite localStorage | — | síncrono, minúsculo, string-only; só para flags |
6.2 Como guardar o CRDT
- Snapshot + updates incrementais: guarde um estado base (snapshot) e, a partir dele, só os updates (deltas) que chegam. Ao carregar, aplique o snapshot e depois os updates.
- Compaction: periodicamente, funda os updates num novo snapshot e descarte os antigos — evita reprocessar milhares de deltas a cada abertura.
- Não serialize o documento inteiro a cada tecla — grave em lote (debounce) ou por transação.
- Persista antes de confirmar a escrita na UI só se for crítico; geralmente a UI já reflete o estado em memória e a persistência é assíncrona logo atrás.
6.3 Transporte
- WebSocket — o padrão para cliente ↔ servidor-relay; simples, funciona atrás de proxy, um servidor por sala/documento escala bem (Durable Objects, Partykit).
- WebRTC (data channels) — peer-to-peer: os clientes trocam updates direto, sem servidor no meio (só um servidor de signaling para se acharem). Latência mínima, sem custo de servidor de dados; mais frágil (NAT, quem entra depois precisa de alguém online, escala P2P limitada).
- HTTP/SSE — alguns sync engines (Electric) usam HTTP long-poll/stream para o sync de shapes; simples de cachear e escalar.
- BroadcastChannel — sincronizar abas do mesmo navegador instantaneamente, sem passar pela rede.
- Service Worker — pode centralizar a conexão e o estado para todas as abas, e continuar sincronizando em background.
6.4 Multi-aba
O mesmo usuário com 3 abas abertas não deve abrir 3 conexões nem divergir entre abas. Padrões: uma aba "líder" mantém a conexão e distribui via BroadcastChannel; ou o Service Worker é o dono; ou cada aba tem sua conexão mas todas convergem pelo CRDT (mais tráfego).
Perguntas: "Onde você guarda o estado local de um app local-first?" (IndexedDB para o binário do CRDT + snapshots/updates; OPFS + SQLite-WASM quando quer SQL reativo), "Snapshot + updates — por quê?" (não reprocessar milhares de deltas; compaction periódica), "WebSocket ou WebRTC?" (servidor-relay simples e escalável vs P2P sem custo de dados mas frágil e com signaling), "Como sincronizar abas do mesmo usuário?" (BroadcastChannel / aba líder / Service Worker).
✏️ Exercício 6 — Persistência e transporte
Projete a camada de persistência e transporte de um app de notas colaborativas web: como guarda no dispositivo, como carrega rápido, como sincroniza entre usuários, entre dispositivos do mesmo usuário, e entre abas. Considere um usuário que abre uma nota de 1 ano com muito histórico.
Gabarito (uma boa resposta): Persistência: por nota, um snapshot + fila de updates em IndexedDB (via y-indexeddb ou equivalente). Ao abrir, carregar o snapshot (rápido) e aplicar os updates pendentes; um job de compaction funde updates num novo snapshot quando passam de N ou de X KB, para a nota de 1 ano não levar segundos para abrir. Histórico antigo (para "ver versões") pode ir para snapshots imutáveis separados, carregados sob demanda. Entre usuários: WebSocket para um servidor-relay "um por nota" (Durable Object/Partykit) que redistribui updates e guarda backup; awareness para presence. Entre dispositivos do mesmo usuário: o mesmo relay + o backup no servidor garante que o outro aparelho baixe o que falta ao abrir. Entre abas: uma aba líder mantém o WebSocket e distribui via BroadcastChannel; as outras abas leem/escrevem no mesmo IndexedDB e recebem updates da líder — uma conexão só. Fallback: se a líder fecha, outra aba assume.
Colaboração em tempo real
Objetivo: presence/awareness, edição concorrente de rich text, comentários e sugestões, atribuição, e undo/redo colaborativo.
7.1 Presence / awareness
- Informação efêmera: quem está online, onde está o cursor/seleção de cada um, o que está digitando, o "está escrevendo…". Não persiste — some quando a pessoa sai; não vai para o CRDT do documento.
- Yjs traz
awareness; sync engines e Liveblocks têm um canal de presence separado. - É o que protege a intenção (Módulo 3): as pessoas se veem e evitam editar o mesmo ponto.
- Cuidados: cor/nome por usuário (consistentes), throttle dos updates de cursor (são muitos), sumir cursores "fantasma" (timeout), acessibilidade (anunciar "Fulano entrou" sem inundar o leitor de tela).
7.2 Rich text concorrente
- Editores desacoplam modelo (a árvore do documento) da view. Os bindings de CRDT ligam o modelo a um
Y.XmlFragment/doc Automerge: ProseMirror/TipTap, Lexical, Slate, CodeMirror 6 (código), BlockNote (blocos). - Formatação (negrito, links) como marks — o CRDT precisa lidar com "aplicar negrito num range que outro editou".
- Blocos (parágrafo, lista, tabela, imagem) como nós de um
Y.Array/map — mover/dividir/juntar blocos concorrente é a parte difícil. - Teste os casos ruins: dois colam texto no mesmo ponto, um apaga o parágrafo que o outro está editando, um transforma em lista enquanto o outro digita.
7.3 Comentários e sugestões
- Comentários ancorados a um range: o range precisa seguir o texto quando ele é editado (âncoras relativas do CRDT, não offsets absolutos). Se o texto ancorado some, o comentário vira "órfão" — decidir o comportamento.
- Modo sugestão (track changes): as edições entram como propostas (inserções/deleções marcadas) que alguém aceita/rejeita — cada sugestão é um objeto no CRDT.
7.4 Atribuição ("quem fez o quê")
- Op-based CRDTs guardam o ID da réplica/autor em cada operação → dá para colorir o texto por autor, mostrar "editado por" e um histórico.
- Automerge tem histórico rico nativo; em Yjs dá para acessar via o modelo de blocos ou uma extensão.
- Cuidado com privacidade: expor "quem escreveu cada palavra" nem sempre é desejável.
7.5 Undo/redo colaborativo
- O undo desfaz o seu último ato, não o da sala. Se você aperta Ctrl+Z, não pode apagar o parágrafo que a colega acabou de escrever.
- Isso exige um UndoManager ciente de autoria, que reverte apenas as operações originadas naquele cliente/escopo (Yjs tem um; trate os casos de borda).
- Redo, seleção após undo, e undo de uma formatação que outro alterou no meio — todos têm arestas; teste.
Perguntas: "O que é awareness/presence e por que não vai no CRDT do documento?" (estado efêmero — cursores, online; some quando a pessoa sai; protege a intenção), "Como um comentário fica ancorado ao texto certo depois de edições?" (âncoras relativas do CRDT, não offsets), "Como funciona undo num editor colaborativo?" (desfaz só as operações do próprio autor — UndoManager ciente de autoria), "Que editores têm binding de CRDT?" (ProseMirror/TipTap, Lexical, CodeMirror, Slate…).
✏️ Exercício 7 — Casos de borda de um editor colaborativo
Liste 6 situações de borda que você testaria num editor de rich text colaborativo, e o comportamento esperado de cada.
Gabarito (uma boa resposta): (1) dois usuários digitam no mesmo ponto → nenhum caractere se perde; o texto converge igual em todos; presence deveria ter avisado. (2) A apaga o parágrafo que B está editando → o parágrafo some para os dois; B não fica "digitando no vazio" de forma incoerente; idealmente B recebe um aviso sutil. (3) A transforma um trecho em lista enquanto B digita nele → converge para uma lista contendo o texto de B. (4) A dá Ctrl+Z → desfaz só a última edição de A, não a de B feita depois. (5) Um comentário ancorado a "prazo: sexta"; alguém troca "sexta" por "segunda" → o comentário segue o range editado; se o range é totalmente apagado, o comentário vira órfão com um estado claro. (6) B fica offline, edita muito, volta → o merge não perde o trabalho de B nem o de quem ficou online; a ordem de blocos converge; nada de tela em branco. Extra: colar 50 KB de texto; dois aplicam negrito em ranges sobrepostos; reconectar com relógio do dispositivo errado (usar timestamp lógico, não wall-clock).
Sync com backend autoritativo
Objetivo: o híbrido real — local-first no cliente + Postgres no servidor: replicação parcial, autorização, validação de escrita, migrações e dados derivados.
8.1 Por que quase todo produto sério é híbrido
Local-first "puro" (P2P, sem servidor) é elegante mas bate em limites: autorização (quem pode ver o quê), validação (o cliente não é confiável), busca e agregações globais, onboarding (novo cliente precisa buscar tudo de alguém), integrações (webhooks, e-mail, cobrança) que vivem no servidor. Solução: o cliente é local-first, e há um servidor autoritativo (tipicamente Postgres) que participa do sync.
8.2 Replicação parcial (shapes / partial sync)
- O cliente não baixa o banco inteiro — baixa "shapes": consultas/subárvores que lhe interessam ("meus projetos", "as tarefas deste board", "as últimas 90 dias").
- O servidor mantém o cliente atualizado sobre essa fatia (streaming de mudanças).
- Mudar de shape (abrir outro projeto) baixa o novo conjunto; sair libera o antigo.
- É o que torna local-first viável para apps com muitos dados (ElectricSQL, Zero, PowerSync giram em torno disso).
8.3 Autorização
- O servidor decide o que cada cliente pode ler (filtra a shape) e escrever.
- Regras por linha/tabela (parecido com RLS — ver Supabase e SQL & Administração de Bancos de Dados): o cliente só recebe as linhas a que tem direito, e escritas fora do escopo são rejeitadas.
- Nunca confie na filtragem do cliente; o CRDT/estado local é potencialmente adversário.
8.4 Validação de escrita
- Modelo comum: o cliente aplica a mudança localmente já (otimista) e a envia; o servidor valida (schema, regras de negócio, permissão) e confirma ou rejeita.
- Se rejeita, o cliente precisa reverter a mudança otimista e avisar o usuário — projete essa experiência (raro, mas acontece: violou uma regra, perdeu a permissão no meio).
- Alguns engines usam "mutators" que rodam igual no cliente (otimista) e no servidor (autoritativo) — a mesma função, duas execuções.
8.5 Migrações de schema com clientes offline
- Um cliente pode voltar depois de meses com dados no formato antigo. O servidor precisa aceitar e migrar.
- Estratégias: mudanças de schema aditivas e compatíveis; versionar o formato do documento e ter migradores para frente (e, idealmente, "lenses"/conversores bidirecionais — ver Project Cambria da Ink & Switch); forçar update do cliente quando a divergência é grande demais.
- Nunca faça uma migração destrutiva assumindo que todos os clientes já atualizaram.
8.6 Dados derivados
- Contadores globais, rankings, busca full-text, "total da equipe", notificações — vivem no servidor, computados a partir do estado sincronizado.
- O cliente consome esses derivados (que também sincronizam para ele), mas não é a fonte deles.
Perguntas: "Por que um app local-first sério ainda precisa de servidor?" (autorização, validação, agregações/busca, onboarding, integrações), "O que é replicação parcial / shapes?" (o cliente sincroniza só o subconjunto relevante; troca de shape ao navegar), "Como funciona a validação de escrita?" (cliente aplica otimista e envia; servidor valida e confirma/rejeita; no rejeita, reverte e avisa), "Como você lida com um cliente que volta após meses offline?" (schema aditivo/compatível, versionar o formato, migradores, forçar update se necessário — nunca migração destrutiva).
✏️ Exercício 8 — Desenhe o híbrido
Um app de gestão de projetos quer ser local-first no cliente com Postgres autoritativo. Descreva: a replicação parcial, a autorização, o caminho de uma escrita (criar tarefa), o que acontece se a escrita viola uma regra, e onde ficam os relatórios agregados.
Gabarito (uma boa resposta): Replicação parcial: ao abrir um projeto, o cliente assina a shape "tarefas + membros + comentários deste projeto" (mais "meus projetos" sempre). O servidor faz streaming das mudanças dessa shape para SQLite/estado local. Autorização: o servidor filtra a shape pelas permissões do usuário (só projetos de que participa) e valida cada escrita contra o papel dele (membro pode criar tarefa; só admin pode arquivar o projeto). Criar tarefa: o cliente insere a tarefa no estado local (aparece na hora), gera a mutação e envia; o servidor valida (schema, projeto existe, usuário é membro, limites do plano) e confirma — os outros clientes da shape recebem. Violação: ex.: o usuário foi removido do projeto entre o abrir e o criar → o servidor rejeita; o cliente reverte a inserção otimista e mostra "Você não tem mais acesso a este projeto". Relatórios agregados ("horas por membro", "burndown"): computados no servidor a partir do estado sincronizado e entregues ao cliente como dados derivados (read-only), atualizados via a mesma stream.
Produção
Objetivo: segurança e E2EE, multi-device, dispositivos que voltam depois de meses, crescimento do documento, testes, observabilidade, custo, longevidade e migração de um app existente.
9.1 Segurança e E2EE
- Em trânsito (TLS/WSS) e em repouso (o binário do CRDT no dispositivo e no servidor) — o básico.
- E2EE opcional: os updates do CRDT são cifrados no cliente; o servidor só armazena e repassa bytes opacos. Isso impede: validação server-side do conteúdo, busca/agregação no servidor, dados derivados a partir do conteúdo, e "sync server inteligente". Gerência de chaves (por dispositivo, por documento) e adicionar um novo colaborador (re-cifrar/compartilhar chave) ficam complexos.
- Muitos produtos fazem um meio-termo: E2EE para o conteúdo sensível, metadados no claro para o servidor operar.
9.2 Multi-device e o "cliente que voltou do frio"
- O mesmo usuário em N dispositivos é um caso de colaboração — trate igual.
- Um dispositivo offline por meses volta com muitas operações locais e muito atraso: o sync precisa aguentar a "avalanche"; o servidor pode precisar mandar um snapshot novo em vez de milhares de deltas; migração de schema (Módulo 8).
- Deteccão de relógio errado no dispositivo — nunca dependa de wall-clock para ordenar; use Lamport/HLC.
9.3 O documento que só cresce
- Sem GC, o CRDT cresce monotonicamente (tombstones, histórico). Estratégias: snapshots periódicos + descartar histórico anterior a um ponto que todos já viram; compaction; arquivar documentos inativos num formato "plano" (JSON/Markdown) e recriar um CRDT novo se reabrirem; limites por documento.
- Meça o tamanho do doc ao longo do tempo em produção — é uma métrica de saúde.
9.4 Testes
- Property-based / fuzz: gerar sequências aleatórias de operações em réplicas simuladas, com partições e reordenação, e assertar convergência (todas terminam iguais) e invariantes (nenhum item perdido, nenhuma árvore com ciclo).
- Simular rede ruim: latência, perda, entrega fora de ordem, duplicação, partição e recuperação.
- Testar os casos de borda de editor (Módulo 7) e de rejeição de escrita (Módulo 8).
- Ver a apostila Testing Moderno & Automação.
9.5 Observabilidade e custo
- Métricas: latência de sync (mudança local → visível em outro cliente), tamanho de doc, updates/s, taxa de reconexão, taxa de rejeição de escrita, divergência detectada.
- Custo: banda (updates trafegados; comprimir, deltar, batelar), armazenamento (snapshots + histórico), conexões persistentes (um WebSocket por cliente/doc — Durable Objects/Partykit ajudam a escalar).
- Ver a apostila Observabilidade & SRE.
9.6 Longevidade e LGPD
- Longevidade: documente o formato do documento; ofereça export para um formato aberto (Markdown, JSON, o que fizer sentido); evite prender o usuário a um binário proprietário sem saída.
- LGPD/privacidade: o dado do usuário agora vive no dispositivo dele, no servidor e em outros dispositivos/colaboradores. "Apagar" precisa propagar (tombstone) e o histórico do CRDT pode reter conteúdo — planeje retenção, "purge" real, e o que acontece com o que já foi replicado para terceiros.
9.7 Migrar um app cloud existente
- Comece pela parte que mais se beneficia (o editor, o board) — não reescreva tudo.
- Rode em paralelo: a nova camada local-first sincroniza com o mesmo backend; feature-flag por usuário.
- Espere reescrever a camada de dados do cliente (de "fetch + cache" para "estado local + sync") — é a maior parte do trabalho.
- Nem tudo vai: relatórios, admin, dados de alta contenção continuam cloud-first.
Depender do relógio do dispositivo para ordenar (use HLC/Lamport). Não ter GC/compaction e ver docs de MB e abertura lenta. Assumir que todos os clientes atualizaram antes de uma migração destrutiva. Confiar na filtragem do cliente para autorização. E2EE "de marketing" sem plano de gerência de chaves e de adicionar colaborador. Não testar partição/reordenação (o bug de convergência só aparece com um cliente que ficou offline). Guardar o CRDT inteiro a cada tecla. Nenhum caminho de export (longevidade quebrada).
Perguntas: "O que a E2EE impede num sistema local-first?" (validação server-side, busca/agregação no servidor, dados derivados do conteúdo; complica gerência de chaves e adicionar colaborador), "Como o documento não cresce para sempre?" (snapshots + descartar histórico visto por todos, compaction, arquivar), "Como você testa convergência?" (property-based com operações aleatórias, partições e reordenação, assertando estado final igual e invariantes), "Como migrar um app cloud para local-first?" (começar pela parte que mais ganha, rodar em paralelo com o mesmo backend, feature-flag; a camada de dados do cliente é o grosso do trabalho).
✏️ Exercício 9 — Plano de produção
Você tem um app de notas colaborativas com Yjs em beta. Antes do GA, liste: 4 riscos de produção e a mitigação de cada; o plano de teste de convergência; e a decisão sobre E2EE.
Gabarito (uma boa resposta): Riscos: (1) documentos incham → snapshots + compaction periódica, "arquivar" notas inativas para Markdown, limite de tamanho com aviso. (2) cliente offline por muito tempo volta com avalanche e schema antigo → servidor manda snapshot novo em vez de deltas, schema só aditivo, versão do formato + migrador, forçar update se divergência grande. (3) relógio errado no dispositivo corrompe ordenação de LWW-registers → usar HLC/Lamport, nunca wall-clock. (4) autorização: o estado local é adversário → o servidor-relay valida cada update contra permissões e filtra a shape; nada de confiar no cliente. Teste de convergência: harness que cria K réplicas simuladas, gera sequências aleatórias de ops (inserir/apagar/formatar/mover bloco), particiona e reconecta em ordens variadas, com duplicação e reordenação, e assere que todas as réplicas terminam com o mesmo documento e sem itens perdidos; rodar em CI com muitas seeds. E2EE: como o produto quer busca full-text no servidor e "compartilhar por link", começar sem E2EE (TLS + criptografia em repouso), com metadados e conteúdo acessíveis ao servidor; oferecer "notas privadas E2EE" como modo opt-in mais tarde, ciente de que nelas não haverá busca no servidor nem colaboração fácil.
Mercado de trabalho: roadmap, entrevistas e portfólio
Objetivo: converter o conteúdo dos módulos em contratação — onde a habilidade é usada, um plano de estudo, um banco de perguntas e projetos que geram entrevista.
10.1 Onde local-first pesa
- Frontend / product engineer em ferramentas de produtividade e colaboração (docs, design, tarefas, código, whiteboard).
- Sync / local-first engineer — cargo cada vez mais explícito nas empresas que constroem esses produtos e nos próprios sync engines.
- Frontend infra / platform — a camada de dados do cliente, offline, performance.
- Mobile — apps de campo, offline-capable, sync com backend.
- Backend — o lado autoritativo: replicação parcial, autorização, validação, migrações.
10.2 Roadmap de estudo (6–8 semanas)
| Semanas | Foco | Prática |
|---|---|---|
| 1 | Ideais, modelo, conflito (Módulos 1–3) | Ler o ensaio da Ink & Switch; mapear 3 apps que você usa no espectro cloud→local |
| 2 | CRDTs (Módulo 4) | Implementar um G-Counter e um OR-Set do zero; brincar com um CRDT de texto pequeno |
| 3 | Yjs (Módulos 5–6) | Um editor colaborativo com Yjs + TipTap + y-webrtc + y-indexeddb; testar offline |
| 4 | Colaboração (Módulo 7) | Adicionar presence (cursores), comentários ancorados e undo por autor; provocar os casos de borda |
| 5 | Sync engine + backend (Módulos 5, 8) | Um app de tarefas com ElectricSQL/Zero/PowerSync + Postgres: replicação parcial + auth + validação |
| 6 | Produção (Módulo 9) | Harness de convergência (property-based + partições); medir tamanho de doc e latência de sync |
| 7–8 | Portfólio | Publicar os projetos com os testes e um write-up de decisões |
10.3 Banco de perguntas (com a resposta que aprova)
Júnior/pleno — "O que é local-first?"
Arquitetura em que os dados vivem no dispositivo como fonte da verdade: leituras e escritas são locais e instantâneas, funciona offline, e a sincronização entre cópias é automática e sem conflito (CRDT). O servidor vira relay/backup, não o dono. Entrega os sete ideais da Ink & Switch: rápido, multi-device, offline, colaborativo, durável, privado, sob controle do usuário.
Pleno — "CRDT vs OT?"
OT (Google Docs) transforma operações concorrentes para aplicarem na ordem certa; é poderoso mas difícil de implementar e costuma exigir um servidor central que serializa. CRDT usa estruturas que convergem por construção (merge comutativo, associativo, idempotente) — permite peer-to-peer e offline sem servidor árbitro, ao custo de metadados, tombstones e GC. Local-first moderno usa CRDT (Yjs, Automerge).
Pleno — "Convergência garante o resultado que o usuário esperava?"
Não. Convergência garante que todas as réplicas chegam ao mesmo estado consistente — não que esse estado é o desejado. Inserções concorrentes no mesmo ponto convergem (ninguém perde dado) mas a ordem pode surpreender; texto pode virar frase sem sentido. Protege-se a intenção com presence (as pessoas se veem), granularidade fina, e às vezes expondo o conflito.
Pleno/sénior — "Qual o custo de um CRDT em produção?"
Metadados por elemento (ID de réplica + contador), pior em texto; tombstones que não somem até todos verem; GC difícil em sistema aberto; crescimento monotônico do documento; possível interleaving de texto. Mitiga-se com libs otimizadas (Yjs/Loro), snapshots + compaction, arquivar docs inativos, e escolher algoritmos que minimizam interleaving.
Sénior — "Yjs sozinho ou um sync engine?"
Yjs (+ hospedagem por documento como Partykit/Durable Objects) quando o caso é "um documento colaborativo" e você quer controle. Um sync engine (ElectricSQL, Zero, PowerSync) quando é "dados relacionais reativos com offline", você tem/quer Postgres, e precisa de replicação parcial, autorização e validação resolvidas. Colab-as-a-service (Liveblocks, Convex) para time-to-market sem operar infra de sync.
Sénior — "Como funciona o modelo híbrido com Postgres autoritativo?"
O cliente é local-first (estado local, escrita otimista, offline) mas sincroniza com um servidor autoritativo. Replicação parcial ("shapes") entrega ao cliente só o subconjunto a que tem direito. O servidor filtra por permissão, valida cada escrita (schema, regras) e confirma/rejeita — no rejeita, o cliente reverte o otimista. Dados derivados (agregações, busca) ficam no servidor. Migrações de schema precisam aceitar clientes que voltam do offline com formato antigo.
Sénior — "O que a criptografia ponta a ponta te tira?"
Se os updates do CRDT são cifrados no cliente, o servidor não pode validar conteúdo, indexar para busca, computar agregações a partir do conteúdo, nem ser um "sync server inteligente" — só guarda e repassa bytes. E gerenciar chaves por dispositivo/documento e adicionar um colaborador (compartilhar/re-cifrar) fica complexo. Muitos produtos fazem híbrido: conteúdo sensível E2EE, metadados no claro.
Armadilha — "É só trocar o fetch por um CRDT"
A camada de dados do cliente muda de paradigma (de "buscar e cachear" para "estado local que sincroniza"), e aparecem problemas novos: convergência sob partição, crescimento do documento, undo por autor, autorização com cliente não confiável, migração de schema para clientes que voltam do frio, presence, e testes property-based. É uma reescrita da camada de dados, não uma troca de biblioteca.
10.4 Projetos de portfólio que geram entrevista
- Editor colaborativo (âncora): rich text com Yjs + um editor (TipTap/Lexical), presence com cursores, comentários ancorados, undo por autor, offline com IndexedDB, e um write-up dos casos de borda testados.
- App relacional com sync engine: um app de tarefas/CRM com ElectricSQL/Zero/PowerSync + Postgres, replicação parcial por escopo, autorização e validação no servidor, e demo de reversão de escrita rejeitada.
- CRDT do zero + harness de convergência: implementar um OR-Set e um counter, e um teste property-based que simula partições/reordenação e prova convergência.
- App P2P sem servidor de dados: algo pequeno (lista compartilhada, jogo) com
y-webrtc, mostrando os limites do P2P (signaling, quem entra depois). - Estudo de migração: pegar um app cloud-first simples e converter uma tela para local-first mantendo o mesmo backend, documentando o que mudou na camada de dados.
10.5 Fontes para continuar
- Fundacional: Local-first software: You own your data, in spite of the cloud (Ink & Switch); CRDT.tech (índice de tipos e papers); os papers e palestras de Martin Kleppmann; o capítulo de replicação de Designing Data-Intensive Applications; Project Cambria (schema evolution) da Ink & Switch.
- Libs: docs do Yjs (e y-* providers, awareness, UndoManager), Automerge / automerge-repo, Loro, Diamond Types.
- Sync engines: docs e blogs de ElectricSQL, Zero/Rocicorp (e o clássico "Replicache" e "Local-First is a Big Deal"), PowerSync, Triplit, Jazz, Instant, LiveStore, Convex, Liveblocks; Partykit e Cloudflare Durable Objects.
- Comunidade: o podcast localfirst.fm; a comunidade e a conferência de local-first; os blogs de engenharia de Linear, Figma (multiplayer), Notion.
- Nesta trilha: Full Stack com TypeScript & Next.js, Supabase, SQL & Administração de Bancos de Dados, NoSQL, Arquitetura de Software & System Design, Testing Moderno & Automação, Observabilidade & SRE, Cibersegurança Prática & DevSecOps, Desenvolvimento Mobile.
Cinco ideias sustentam local-first: (1) o dispositivo é a fonte da verdade — ler/escrever sem rede, sync em background, servidor como relay; (2) conflito é inevitável e a resposta é CRDT (converge por construção; custo de metadados, tombstones, GC); (3) Yjs/Automerge + presence resolvem o "documento colaborativo"; sync engines resolvem "dados relacionais reativos com offline"; (4) produtos sérios são híbridos — local-first no cliente + Postgres autoritativo com replicação parcial, autorização e validação; (5) produção exige testes de convergência, GC/compaction, migração para clientes offline, e um plano de longevidade e LGPD.