Local-First Software: principles and manifesto 2027
Local-First Software is the paradigm that places complete data ownership in the hands of the user. This is not a passing trend: it is a structural response to the failures of 20 years of cloud-only architecture. When Figma lost server access for 2 hours in 2024, millions of designers couldn't work. When Google Docs went offline, freelancers lost productivity. When Notion suffered a ransomware attack, the data of thousands of startups was at risk. Local-First Software eliminates this dependency: data lives first on the user's device, synchronization is optional, the server is an implementation detail.
This article examines the fundamental principles of Local-First Software, the 2027 manifesto, and how to build applications that put users in control. We'll describe CRDTs (Conflict-free Replicated Data Types), libraries like Automerge and Yjs, performance trade-offs, and a checklist for migration.
What You'll Learn
- Definition of Local-First Software and the fundamental principles of the 2027 manifesto
- CRDT (Conflict-free Replicated Data Types): what it is, when to use it, and how it works
- Automerge and Yjs: comparison, use cases, and practical implementation
- Peer-to-peer synchronization vs centralized server: hybrid architectures
- Offline-first as default: paradigm shift in UX
- Privacy by default: ownership and end-to-end encryption
- Performance and trade-offs: local database size vs UX quality
- Migration from cloud-only to Local-First: roadmap and incremental patterns
- Tools and recommended stack: SQLite WASM, IndexedDB, RediSearch for sync
- Real-world use cases: Notion, Figma, Obsidian and the emerging pattern
What is Local-First Software?
Local-First Software is an architecture where the primary state of the application lives on the user's device, not on the server. The server becomes an optional peer for synchronization, backup, and cross-device access. Your data never leaves your computer without explicit consent. You can work offline indefinitely and changes automatically synchronize when connectivity returns. Two users editing the same document simultaneously don't cause conflicts: CRDTs resolve edit merges algorithmically.
This approach was formalized by the paper "Local-First Software" by Martin Kleppmann (Figma) and Orion Henry (Ink&Glint) in 2019. By 2027, it has become a de facto standard for collaborative applications: Figma, Obsidian, Notion Private Instance, Linear, Retool, and dozens of startups build exclusively on Local-First.
The 7 Principles of the 2027 Manifesto
1. Your data is yours
Data does not belong to the company: it belongs to you. You can export it, delete it, or move it to another application without technical constraints. This isn't a marketing pitch: it's implemented via end-to-end encryption and a local database. No server ever reads your unencrypted database.
2. Offline-first is the design default
The application must work completely offline. Synchronization is not an option: it happens naturally when the network returns, without UX compromises. There are no intermittent "loading states" waiting for the server.
3. Conflicts resolved algorithmically
When two users edit the same document, CRDTs guarantee that both edits are preserved and applied in a deterministic order. There's no "last write wins": every edit matters.
4. Transparent and continuous synchronization
Changes synchronize between devices and peers without user intervention. The server is one of many peers, not the authoritative controller.
5. The network is fragile, design accordingly
Assume that connection will be lost. Design exponential retries, sync batching, and queuing of offline changes. Network latency should never block the UX.
6. Encryption by default
All data in transit is encrypted. Data at rest on servers is encrypted. Only you know the keys.
7. Immutable audit trail
Every change is logged with timestamp, author, and cryptographic signature. It's impossible to alter history without detection.
CRDT: Conflict-free Replicated Data Types
A CRDT is a data structure that guarantees convergence without central coordination. If two replicas of the same CRDT receive the same edits in different order, they end in the same final state. They don't require an arbiter, they don't require locking: each replica is autonomous.
There are two categories: operation-based (OT - Operational Transform, used by Google Docs) and state-based (used by Automerge and Yjs). State-based CRDTs are simpler to implement and robust in the absence of ordering.
// Example: simple CRDT for a counter
class Counter {
private replicas: Record<string, number> = {};
increment(replicaId: string) {
this.replicas[replicaId] = (this.replicas[replicaId] || 0) + 1;
}
value(): number {
// The value is the sum of all replicas
return Object.values(this.replicas).reduce((a, b) => a + b, 0);
}
// Merge with another replica: converges automatically
merge(other: Counter) {
for (const [id, val] of Object.entries(other.replicas)) {
this.replicas[id] = Math.max(this.replicas[id] || 0, val);
}
}
}
// Usage
const counter1 = new Counter();
const counter2 = new Counter();
counter1.increment('alice');
counter1.increment('alice');
counter2.increment('bob');
console.log(counter1.value()); // 2
console.log(counter2.value()); // 1
counter1.merge(counter2);
console.log(counter1.value()); // 3 (convergence)
counter2.merge(counter1);
console.log(counter2.value()); // 3 (mutual convergence)
Real CRDTs (for text, lists, maps) are more complex. They use Lamport clocks or Version Vectors to order edits, and structural sharing for efficiency. Automerge and Yjs hide this complexity behind friendly APIs.
Automerge and Yjs: Practical Comparison
Automerge (JavaScript/Rust)
Automerge is a CRDT written in Rust, with JavaScript bindings. It is immutable by design: every modification creates a new version. Great for audit trails, time-travel debugging, and preserving complete history.
import * as Automerge from 'automerge';
// Create a document
let doc1 = Automerge.init();
// Modify (creates a new version)
doc1 = Automerge.change(doc1, d => {
d.tasks = [];
d.tasks.push({ title: 'Buy milk', done: false });
});
// Clone for a second user
let doc2 = Automerge.clone(doc1);
// Independent modification
doc1 = Automerge.change(doc1, d => {
d.tasks[0].done = true;
});
doc2 = Automerge.change(doc2, d => {
d.tasks.push({ title: 'Buy bread', done: false });
});
// Merge: converges without conflicts
const merged = Automerge.merge(doc1, doc2);
console.log(merged.tasks);
// [{ title: 'Buy milk', done: true }, { title: 'Buy bread', done: false }]
Yjs (JavaScript/Rust)
Yjs is mutable and has superior performance for large documents. It's Figma's choice for collaborative editing. It uses an internal CRDT optimized for speed.
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';
// Create a document
const ydoc = new Y.Doc();
const yarray = ydoc.getArray('tasks');
// Add items
yarray.push([{ title: 'Buy milk', done: false }]);
// Sync via WebSocket
const provider = new WebsocketProvider(
'ws://localhost:1234',
'shared-task-list',
ydoc
);
// Bind to UI framework (React, Vue, etc.)
provider.on('sync', (isSynced) => {
console.log('Synced:', isSynced);
});
// Observe changes
yarray.observe((event) => {
event.changes.added.forEach((item) => {
console.log('Item added:', item);
});
});
When to choose: Automerge for audit trails and immutability; Yjs for performance and real-time collaborative editing.
Synchronization: P2P vs Centralized Server
Peer-to-Peer (Direct Sync)
Two apps sync directly via WebRTC or direct WebSocket. There's no intermediate server (or it's optional for NAT traversal). Example: Obsidian Sync uses P2P end-to-end encrypted synchronization for local vault syncing.
// Pseudo-code: P2P sync with WebRTC Data Channel
import SimplePeer from 'simple-peer';
const peer = new SimplePeer({ initiator: true, trickle: false });
peer.on('connect', () => {
// Send our CRDT state
const state = Automerge.save(doc);
peer.send(state);
});
peer.on('data', (data) => {
// Receive peer's state
doc = Automerge.merge(doc, Automerge.load(data));
});
Centralized Server (Hub)
The server is the canonical coordinator. It receives all edits from all clients, maintains complete history, and redistributes them. Simpler to implement, but introduces a single point of failure.
// Backend (Node.js): receive changes and broadcast
import WebSocket from 'ws';
import * as Automerge from 'automerge';
const wss = new WebSocket.Server({ port: 8080 });
let serverDoc = Automerge.init();
const clients = new Set();
wss.on('connection', (ws) => {
clients.add(ws);
// Send current state to new client
ws.send(JSON.stringify({ type: 'init', doc: Automerge.save(serverDoc) }));
// Receive changes from client
ws.on('message', (message) => {
const { type, change } = JSON.parse(message);
if (type === 'change') {
serverDoc = Automerge.applyChanges(serverDoc, [change])[0];
// Broadcast to all clients
clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(JSON.stringify({ type: 'change', change }));
}
});
}
});
});
Offline-First: UX Paradigm Shift
Offline-first means the application should never make a server request before completing an action. Write locally, sync afterward. This radically changes the UX flow:
- Don't wait for the server: The action completes immediately locally. If sync fails later, it retries automatically.
- Conflicts become opportunities: Two users edit the same document offline. CRDTs resolve the merge. Show the result, not a "conflict resolved".
- Sync status badge: Discrete icon showing "synced", "syncing", or "sync failed with retry in progress".
- Local queue: If the user makes 10 edits and the network is offline, queue all operations. When the network returns, send the batch atomically.
// Offline-first implementation in Angular with Signals
import { signal, computed, effect } from '@angular/core';
export class DocumentService {
private doc = signal(Automerge.init());
private networkStatus = signal<'online' | 'offline'>('online');
private syncQueue = signal<Automerge.Change[]>([]);
isSyncing = computed(() => this.syncQueue().length > 0);
canEdit = signal(true); // Always true in local-first
constructor(private http: HttpClient) {
effect(() => {
if (this.networkStatus() === 'online' && this.syncQueue().length > 0) {
this.flushSyncQueue();
}
});
}
addTask(title: string) {
// Modify locally IMMEDIATELY
this.doc.update((doc) => {
doc = Automerge.change(doc, (d) => {
if (!d.tasks) d.tasks = [];
d.tasks.push({ id: crypto.randomUUID(), title, done: false });
});
return doc;
});
// Queue the sync
const changes = Automerge.getChanges(this.lastSyncedVersion, this.doc());
this.syncQueue.update((q) => [...q, ...changes]);
}
private async flushSyncQueue() {
const changes = this.syncQueue();
if (changes.length === 0) return;
try {
await this.http.post('/api/sync', { changes }).toPromise();
this.syncQueue.set([]);
this.lastSyncedVersion = this.doc();
} catch (error) {
// Automatic retry via exponential backoff
setTimeout(() => this.flushSyncQueue(), 5000);
}
}
}
Privacy by Default: End-to-End Encryption
In Local-First Software, your data never passes to the server in clear text. End-to-end encryption (E2E) is used with keys controlled only by you. The server stores the encrypted CRDT, cannot read it.
- Key derived from password: Your password generates an encryption key via PBKDF2 or Argon2.
- Selective sharing: To share a document with a colleague, share the decryption key securely (not email).
- Revoke access: Re-encrypt the document with a new key, if necessary. The removed user won't have access to new versions.
This is implemented in Obsidian Sync, Standard Notes, and Notion's private roadmap. It's not a "nice to have": it's a requirement of the 2027 manifesto.
Performance: Local Database and Synchronization
Continuous CRDT synchronization has a cost. The document grows in size (because it preserves history). Write performance decreases if the local database becomes too large. You must manage:
- Snapshot and compaction: Periodically, write a CRDT snapshot. Delete earlier history. Reduces disk size and loading time.
- Pagination for large collections: If you have 1M records, don't load 1M into memory. Use virtual scrolling and load-on-demand.
- IndexedDB vs SQLite WASM: IndexedDB is native in browsers, but slow. SQLite WASM (via sql.js or wa-sqlite) is faster and familiar to SQL developers.
// Snapshot and compaction in Automerge
function compactDocument(doc) {
// Create a snapshot without history
const heads = Automerge.getHeads(doc);
const compacted = Automerge.init();
// Copy complete state to the new doc
Object.assign(compacted, doc);
// The compacted has reduced size (no history)
const originalSize = Automerge.save(doc).length;
const compactedSize = Automerge.save(compacted).length;
console.log(`Size reduction: ${originalSize}B -> ${compactedSize}B`);
return compacted;
}
// Use SQLite WASM for fast queries
import initSqlJs from 'sql.js';
const SQL = await initSqlJs();
const db = new SQL.Database();
db.run(`
CREATE TABLE documents (
id TEXT PRIMARY KEY,
crdt_data BLOB,
last_synced INTEGER
);
`);
// Save compressed CRDT
const crdt = Automerge.save(doc);
db.run('INSERT INTO documents (id, crdt_data) VALUES (?, ?)', ['doc-123', crdt]);
Incremental Migration to Local-First
You don't need to change an entire architecture overnight. You can adopt Local-First incrementally:
Phase 1: Add local cache
Keep the backend identical. Add an IndexedDB or SQLite local cache that copies server data. When offline, read from the local cache. When online, sync and write to the server as before.
Phase 2: Immediate local writes
The user writes locally, and the UI updates immediately. In the background, sync sends to the server. If it fails, the write remains locally (eventually consistent).
Phase 3: CRDT for conflicts
Enable CRDT only for features where conflicts are frequent (collaborative docs, task lists). Other features remain server-authoritative.
Phase 4: P2P sharing (optional)
Two users can sync directly via WebRTC without going through the server. The server remains for backup and multi-device access.
Recommended Technology Stack
To build Local-First Software in 2027, I recommend:
- Frontend: Angular, React, or Vue + Signals/Composition API for reactivity
- CRDT: Automerge for immutability + audit trail; Yjs for pure performance
- Local storage: SQLite WASM (wa-sqlite) for fast queries, or IndexedDB for simplicity
- Sync: WebSocket + exponential backoff for connection loss; WebRTC Data Channel for P2P
- Encryption: TweetNaCl.js for crypto, Libsodium for secret-box sealed boxes
- Backend (optional): Node.js + PostgreSQL for audit logs and backup of latest CRDT versions
You don't need to use all of these: choose only what solves your constraints. An offline-first todo app needs only Automerge + IndexedDB. A collaborative editor needs Yjs + WebSocket + SQLite.
Local-First Applications in 2027
- Figma: Collaborative design editor. Internal CRDT for edit merge, local cache for offline mode.
- Obsidian: Note-taking app. Everything offline-first, optional synchronization via Obsidian Sync (E2E encrypted).
- Linear: Issue tracking. Real-time collab on issue description, offline queue for new issues.
- Notion Private Instance: Personal database with Local-First option for private organizations.
- GitBook: Collaborative wiki. Uses Yjs for sync, WebSocket for real-time.
Frequently Asked Questions
Q: Isn't it slower to sync a CRDT versus sending a single command to the server?
A: Yes, sending all CRDT changes is slower. But it's faster for the user because they don't wait for the server. The UI updates immediately, sync happens in the background. The trade-off is: reduced visible latency (better UX) vs increased bandwidth (doesn't matter for most cases).
Q: What happens if two users simultaneously delete the same element?
A: The CRDT handles this. The delete is recorded. If the element is brought back to life by another modification, the delete prevails (tombstone). There's no "conflict": the CRDT has a deterministic order.
Q: How do I handle multi-document transactions (atomicity)?
A: Local-First doesn't have ACID transactions by nature. Use event sourcing: every modification is an immutable event. If an action requires atomicity (money transfer), record the intent as an event, sync, and the server decides if it's valid.
Q: What if I want to revoke a user's access to an encrypted document?
A: Re-encrypt the document with a new key. The removed user still has access to previous versions (if offline), but not to new ones. For absolute revocation, you need to rewrite history (impractical). Use per-user keys instead of global keys.
Q: Can I use Local-First for a financial application?
A: With caution. Local-First is great for responsive UI, but critical operations (money transfer, balance decrements) must be validated by the server. Use Local-First for light UX, but the server remains the authority for financial data consistency.

Commenti
Caricamento commenti...
Sign in to comment