CRDTs for Offline-First Apps: Practical Guide [2026]
Bottom Line
A CRDT is not just a sync trick. It is the data model that lets your app accept writes offline, merge concurrent edits deterministically, and recover state without a central lock.
Key Takeaways
- ›Use a CRDT repo plus local storage so every write succeeds offline first.
- ›Automerge docs currently expose JS API version 3.2.6 and Repo docs 2.5.5.
- ›IndexedDB handles browser persistence; BroadcastChannel syncs tabs on the same origin.
- ›Only concurrent writes to the same property need conflict inspection with getConflicts().
CRDTs solve the hardest part of offline-first engineering: letting multiple clients accept writes independently and still converge on one consistent state later. The practical shift is architectural, not cosmetic. You stop treating the server as the place where edits become real, and instead store real state on every client. In this walkthrough, you will build a minimal notes app with Automerge, browser persistence, and local peer sync so you can see CRDT behavior instead of just reading about it.
- CRDT-backed clients can commit edits immediately, even with no network.
- This tutorial aligns with Automerge 3.2.6 JS docs and Automerge Repo 2.5.5 docs.
- IndexedDB gives you durable local state; BroadcastChannel keeps same-origin tabs in sync.
- Concurrent list edits usually merge cleanly; concurrent writes to one scalar field need policy.
Prerequisites
Prerequisites Box
- A recent Node LTS that satisfies Vite's documented requirement of 20.19+ or 22.12+.
- Basic TypeScript and DOM event handling.
- A browser with IndexedDB and BroadcastChannel support.
- A decision to treat local state as authoritative for user interactions, not just as a cache.
Bottom Line
Offline-first apps become tractable when every client stores and mutates the same CRDT document shape locally, then syncs operations instead of serializing server-approved writes.
For a minimal browser build, plain Vite is enough. If you paste larger event handlers into the demo, clean them up with TechBytes' Code Formatter before debugging merge behavior across tabs.
Step 1: Create the document
Scaffold the app and install the CRDT packages
- Create a Vite TypeScript app. The Vite docs still use the --template flag for non-interactive scaffolding.
- Install the Automerge repo package and the browser adapters you need for storage and local sync.
npm create vite@latest crdt-notes -- --template vanilla-ts
cd crdt-notes
npm install @automerge/automerge-repo @automerge/automerge-repo-storage-indexeddb @automerge/automerge-repo-network-broadcastchannelModel one durable document
Start with a single note document. That sounds small, but it is the right CRDT habit: define the unit of merge first. In offline-first systems, vague state boundaries create more pain than the CRDT library itself.
import { Repo } from "@automerge/automerge-repo";
import { IndexedDBStorageAdapter } from "@automerge/automerge-repo-storage-indexeddb";
import { BroadcastChannelNetworkAdapter } from "@automerge/automerge-repo-network-broadcastchannel";
type ChecklistItem = {
id: string;
text: string;
done: boolean;
};
type NoteDoc = {
title: string;
body: string;
checklist: ChecklistItem[];
updatedAt: string;
};
const repo = new Repo({
storage: new IndexedDBStorageAdapter(),
network: [new BroadcastChannelNetworkAdapter()],
});The important decision here is that the document contains app state, not transport state. You are not storing pending requests, optimistic patches, or server diffs. You are storing the note itself.
Step 2: Persist offline
Create or reopen a document by URL
- Read a
docparameter from the URL. - If it exists, load the existing Automerge document with Repo.find.
- If it does not exist, create one with Repo.create and write the new document URL back into the address bar.
const root = document.getElementById("app")!;
const params = new URLSearchParams(location.search);
const existingUrl = params.get("doc");
const handle = existingUrl
? await repo.find<NoteDoc>(existingUrl)
: repo.create<NoteDoc>({
title: "Offline-first release checklist",
body: "Edit this note in two tabs to watch CRDT merge behavior.",
checklist: [
{ id: crypto.randomUUID(), text: "Cache writes locally", done: false },
{ id: crypto.randomUUID(), text: "Sync in background", done: false }
],
updatedAt: new Date().toISOString()
});
if (!existingUrl) {
params.set("doc", handle.url);
history.replaceState({}, "", `?${params.toString()}`);
}This is where the offline-first property becomes concrete. The document URL is stable, the document is stored in IndexedDB, and the client can reopen it after a refresh without asking a server for permission.
Render from document state only
Do not maintain a second shadow model in memory unless you need one. Your UI should derive from the CRDT document so reloads, reconnects, and remote updates all flow through one path.
function render(doc: NoteDoc) {
root.innerHTML = `
<main>
<h1>CRDT Notes Demo</h1>
<input name="title" value="${doc.title}" />
<textarea name="body" rows="6">${doc.body}</textarea>
<ul>
${doc.checklist
.map(
item => `<li>
<label>
<input type="checkbox" name="done" data-id="${item.id}" ${item.done ? "checked" : ""} />
${item.text}
</label>
</li>`
)
.join("")}
</ul>
<p>Updated: ${doc.updatedAt}</p>
</main>
`;
}
render(await handle.doc());Step 3: Sync and merge
Write every user action through the CRDT handle
The core rule is simple: local edits must go through DocHandle.change. That causes the repo to persist the change and distribute it to connected peers.
root.addEventListener("input", event => {
const target = event.target;
if (!(target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement)) return;
handle.change(doc => {
if (target.name === "title") doc.title = target.value;
if (target.name === "body") doc.body = target.value;
doc.updatedAt = new Date().toISOString();
});
});
root.addEventListener("change", event => {
const target = event.target;
if (!(target instanceof HTMLInputElement) || target.name !== "done") return;
const id = target.dataset.id;
if (!id) return;
handle.change(doc => {
const item = doc.checklist.find(entry => entry.id === id);
if (item) item.done = target.checked;
doc.updatedAt = new Date().toISOString();
});
});Subscribe to remote and local updates
One listener handles both. When another tab changes the document, the change event fires and the UI redraws from the latest merged state.
handle.on("change", ({ doc }) => {
render(doc);
});At this point, you already have the essential offline-first loop:
- User edits are applied locally first.
- The repo stores them durably in IndexedDB.
- The network adapter forwards them to other peers when available.
- Every peer converges on the same document state.
Verify and troubleshoot
Expected output
- Run
npm run devand open the generated local URL in two tabs. - In tab A, edit the title and toggle a checklist item.
- In tab B, refresh once to confirm the URL contains a
docparameter that begins withautomerge:, then keep both tabs open. - Make a new edit in tab A and watch tab B update automatically.
- Disconnect your network, edit either tab, then reconnect. The note should still be present because storage is local, and new peer sync should resume when connectivity returns.
Expected behaviors:
- The URL includes something like: automerge:abc123...
- Refreshing does not erase the note
- Two tabs on the same origin converge after edits
- Concurrent edits to different fields merge cleanlyTop 3 troubleshooting fixes
- No sync between tabs: make sure both tabs are on the same origin. BroadcastChannel is same-origin only, and Automerge docs note it is mainly a simple local adapter.
- State disappears on refresh: verify the repo was created with IndexedDBStorageAdapter. Without a storage adapter, your repo is transient.
- One field shows an unexpected winner after concurrent edits: that is the normal CRDT edge case for two writes to the same property. Automerge exposes the visible winner deterministically, and you can inspect alternates with Automerge.getConflicts() if your product needs a custom UI.
What's next
This demo proves the local-first loop in one browser. The next production steps are straightforward:
- Replace the tab-only adapter with WebSocketServerAdapter and WebSocketClientAdapter to sync across devices.
- Split large datasets into multiple documents so sync cost stays bounded.
- Add explicit conflict UI for high-value fields where a hidden alternate value is unacceptable.
- Keep secrets and regulated fields out of casual fixtures; if you need sample customer data, sanitize it first with the TechBytes Data Masking Tool.
The main takeaway is operational: once local writes become the default path, offline mode stops being a fallback feature and becomes the system's normal behavior. That is the real value of CRDTs in application architecture.
Frequently Asked Questions
What is the difference between a CRDT and optimistic UI? +
Do CRDTs eliminate all merge conflicts? +
Automerge.getConflicts().Can I use CRDTs without peer-to-peer networking? +
Are CRDTs a good fit for every offline-first app? +
Get Engineering Deep-Dives in Your Inbox
Weekly breakdowns of architecture, security, and developer tooling — no fluff.