Building a Local-First App with CRDTs

· 4 min read
crdt yjs local-first typescript

Prerequisites

  • React/TypeScript knowledge (the real kind, not “I watched a YouTube tutorial once”)
  • Basic understanding of conflict resolution challenges
  • A functioning internet connection (at least some of the time, that’s sort of the whole point)
  • Willingness to have your mind slightly blown
  • One mass market jar of peanut butter, smooth, non-negotiable

What We’re Building

A notes app that works offline, syncs automatically when online, and never loses data. Even with conflicting edits from multiple devices. Think Google Docs minus the part where Google knows what you had for breakfast.

If youve ever opened an app on a plane, made edits, landed, and watched everything vanish into the void, this post is for you. We’re going to make that impossible.

The Approach

  1. Understand what CRDTs solve
  2. Set up Yjs with persistence
  3. Build collaborative editing
  4. Add sync via WebSocket or WebRTC

Seems straightforward enough. Famous last words.

Elmo surrounded by fire

Step 1: Why CRDTs?

Traditional sync gives you two options: “last write wins” (hope you weren’t attached to those edits) or manual conflict resolution (hope you enjoy merge dialogs at 11pm).

CRDTs (Conflict-free Replicated Data Types) take a different approach entirely. All changes merge automatically, deterministically, without coordination. Edit offline for a week, come online, everything merges. No conflicts. No data loss. No drama.

Confused Math Lady trying to understand how CRDTs work

I know. It sounds too good to be true. It isnt.

Step 2: Set Up Yjs

npm install yjs y-indexeddb y-websocket

Three packages, zero regret. yjs is the CRDT engine, y-indexeddb handles local persistence, and y-websocket deals with the network sync.

Create src/store.ts:

import * as Y from 'yjs';
import { IndexeddbPersistence } from 'y-indexeddb';

export const ydoc = new Y.Doc();

// Persist to IndexedDB
const persistence = new IndexeddbPersistence('my-app', ydoc);

persistence.on('synced', () => {
  console.log('Loaded from IndexedDB');
});

// Shared data types
export const yNotes = ydoc.getMap<Y.Text>('notes');

That synced event fires once the local IndexedDB data has been loaded into the Yjs document. Your app is usable before any network request completes. Thats the local-first bit doing its thing.

Step 3: Create React Hooks

This is where it gets properly satisfying. We wrap the Yjs observables in a React hook so our components stay blissfully unaware of the CRDT machinery underneath.

import { useEffect, useState } from 'react';
import * as Y from 'yjs';
import { yNotes } from './store';

export function useNote(id: string) {
  const [content, setContent] = useState('');

  useEffect(() => {
    let yText = yNotes.get(id);

    if (!yText) {
      yText = new Y.Text();
      yNotes.set(id, yText);
    }

    const updateContent = () => setContent(yText!.toString());

    yText.observe(updateContent);
    updateContent();

    return () => yText!.unobserve(updateContent);
  }, [id]);

  const updateNote = (newContent: string) => {
    const yText = yNotes.get(id);
    if (yText) {
      yText.delete(0, yText.length);
      yText.insert(0, newContent);
    }
  };

  return { content, updateNote };
}

The observe callback fires every time the underlying CRDT changes, whether from local edits or remote sync. React re-renders. Everyone’s happy. The delete then insert pattern in updateNote is a bit naive for production (you’d want proper diffing), but for understanding the flow it does the job.

Step 4: Add Network Sync

Now we connect devices. This is the part that feels like witchcraft.

import { WebsocketProvider } from 'y-websocket';
import { ydoc } from './store';

// Connect to sync server
const wsProvider = new WebsocketProvider(
  'wss://your-server.com',
  'my-app-room',
  ydoc
);

wsProvider.on('status', ({ status }: { status: string }) => {
  console.log('Connection:', status); // connected, disconnected
});

The server can be a simple relay:

npx y-websocket

That’s it. The server doesnt need to understand your data model, resolve conflicts, or do anything clever. It just passes messages between clients. All the intelligence lives in the CRDT on each device.

Frankenstein saying It’s alive

Step 5: Use in Components

function NoteEditor({ id }: { id: string }) {
  const { content, updateNote } = useNote(id);

  return (
    <textarea
      value={content}
      onChange={(e) => updateNote(e.target.value)}
    />
  );
}

Look at that. A textarea that works offline, syncs across devices, and handles conflicts automatically. It dont look like much, but there’s a surprising amount of distributed systems theory hiding behind those few lines.

The Result

  • Fully offline-capable
  • Instant local updates (no loading spinners, ever)
  • Automatic conflict resolution
  • Changes sync across devices in real-time when online

Your users will never see a conflict dialog. They’ll never lose work. They probably won’t even notice, which is exactly the point. The best infrastructure is the kind nobody thinks about.

Mind blown

What I’d Do Differently

Use Y.XmlFragment with a proper rich-text binding (like Tiptap + y-prosemirror) for text editing. Plain Y.Text works but lacks formatting support. If your users want bold and italics, and they always want bold and italics, you’ll want that richer integration from the start.

CRDTs feel like magic until you understand them. Then they feel like the obvious way every app should have been built all along.

Related Posts

Comments