Skip to main content

Local-First Architecture

AFFiNE is built on local-first principles, ensuring your data is always available, even without internet connectivity. Your workspace lives primarily on your device, with cloud sync as an enhancement—not a requirement.

What is Local-First?

Local-first software prioritizes:

Offline Work

Full functionality without internet connection

Speed

Instant response—no server round-trips for reads

Ownership

Your data lives on your device first

Privacy

No forced cloud storage or tracking

Sync

Cloud backup and multi-device sync when online

Reliability

Works even if servers go down

Architecture Overview

AFFiNE’s local-first stack:
┌─────────────────────────────────────────┐
│         Application Layer               │
│  (Editor, Whiteboard, Database)         │
└────────────────┬────────────────────────┘

┌────────────────▼────────────────────────┐
│           Y.js (Yjs) CRDT               │
│  (Conflict-free data structures)        │
└────────────────┬────────────────────────┘

         ┌───────┴────────┐
         │                │
┌────────▼──────┐  ┌──────▼──────────────┐
│  IndexedDB    │  │  WebSocket Sync     │
│  (Local)      │  │  (Cloud/Server)     │
└───────────────┘  └─────────────────────┘
         │                │
         │                │
    Your Device      AFFiNE Server
                    (PostgreSQL + Y.js)

Core Technologies

Y.js (Yjs) CRDT

Conflict-free Replicated Data Type—the foundation of AFFiNE’s sync:
CRDT Properties:
  • Commutative: Order of operations doesn’t matter
  • Associative: Grouping of operations doesn’t matter
  • Idempotent: Applying same operation twice = applying once
Benefits:
  • No merge conflicts ever
  • Offline editing with guaranteed sync
  • Peer-to-peer sync (no server required)
  • Time-travel and undo/redo
  • Efficient binary encoding
Data Types:
  • Y.Doc - Document container
  • Y.Text - Collaborative text
  • Y.Array - Ordered list
  • Y.Map - Key-value store
  • Y.XmlFragment - Rich text with formatting
Update Mechanism:
// Every change creates an update
ydoc.on('update', (update: Uint8Array) => {
  // Send to peers or save locally
  saveToIndexedDB(update);
  sendToServer(update);
});

// Apply remote updates
Y.applyUpdate(ydoc, remoteUpdate);

IndexedDB Storage

Browser-based local database:
  • Persistent: Data survives browser restarts
  • Large capacity: Gigabytes of storage
  • Async API: Non-blocking operations
  • Indexed queries: Fast lookups
  • Transactional: ACID guarantees
What AFFiNE Stores Locally:
  • All page content (Y.js updates)
  • Document metadata (title, created date)
  • Edit history (for undo/redo)
  • Unsent updates (when offline)

WebSocket Sync Protocol

Real-time bidirectional communication:
// Socket.io connection flow
1. Client connectsAuthenticate
2. space:joinJoin workspace room
3. space:load-docGet initial state
4. space:push-doc-updateSend local changes
5. space:broadcast-doc-updatesReceive remote changes
6. Y.applyUpdate() → Merge into local doc
Protocol Versions:
  • sync-025: Legacy single-update broadcasts
  • sync-026: Optimized batch updates (current)
The server automatically detects client version (≥0.25.0) and uses the appropriate sync protocol.

Sync Flow

Initial Load

When opening a workspace:
1. Load from IndexedDB → Instant display
2. Connect to WebSocket → Background sync
3. Request server state → Check for updates
4. Merge remote changes → Update local copy
5. Display "Synced" indicator
Result: Near-instant load times, with server sync in background.

Online Editing

When making changes while online:
1. User types → Y.js creates update
2. Update saved to IndexedDB → Local persistence
3. Update sent via WebSocket → Server receives
4. Server broadcasts → Other clients receive
5. Other clients apply → Collaborative edit
6. Server persists to PostgreSQL → Cloud backup
Latency: ~50-200ms from keystroke to remote user’s screen.

Offline Editing

When internet disconnects:
1. User types → Y.js creates update
2. Update saved to IndexedDB → Local only
3. Updates queue in memory → Pending sync
4. UI shows "Offline" indicator
5. User continues working → No interruption

(When connection returns)
6. WebSocket reconnects → Automatic
7. Queued updates sent → Batch upload
8. Server merges → CRDT magic
9. UI shows "Synced" → Complete
Key Point: Zero data loss, automatic conflict resolution.

Conflict Resolution

How Conflicts Are Prevented

Y.js uses operation-based CRDTs to eliminate conflicts:
Scenario 1: Concurrent Text EditsInitial: "Hello World"
  • User A (offline): Inserts “Beautiful ” at position 6 → "Hello Beautiful World"
  • User B (offline): Inserts “Amazing ” at position 6 → "Hello Amazing World"
Both reconnect:
  • Y.js merges based on operation metadata (user ID, timestamp)
  • Result: "Hello Beautiful Amazing World" (deterministic order)
  • No “conflict markers” or manual resolution needed
Scenario 2: Block Deletion vs. Edit
  • User A: Deletes paragraph
  • User B: Edits same paragraph
Merge:
  • Y.js preserves deletion
  • Edit operations marked as applying to deleted block
  • Paragraph stays deleted (deletion takes precedence)
  • Edit history preserved for potential recovery
Scenario 3: Property Changes
  • User A: Changes color to “red”
  • User B: Changes color to “blue”
Merge:
  • Last-write-wins based on timestamp
  • Both updates have timestamps
  • Later timestamp wins
  • Deterministic across all clients

State Vectors

Efficient sync using state vectors:
// State vector = what updates I've seen
const stateVector = Y.encodeStateVector(ydoc);
// → "I have updates 1-100 from User A, 1-50 from User B"

// Server calculates diff
const diff = Y.encodeStateAsUpdate(serverDoc, stateVector);
// → "You're missing updates 101-120 from User A"

// Client applies only missing updates
Y.applyUpdate(ydoc, diff);
Benefits:
  • Minimal bandwidth (only send what’s needed)
  • Fast catch-up for offline clients
  • No full document re-download

Storage Architecture

Client-Side (IndexedDB)

// Storage schema
Workspace
  ├── Documents
  │   ├── doc-id-1Y.js updates
  │   ├── doc-id-2Y.js updates
  │   └── ...
  ├── Blobs (images, files)
  │   ├── blob-id-1ArrayBuffer
  │   └── ...
  └── Metadata
      ├── sync-state
      ├── user-preferences
      └── workspace-config
Garbage Collection:
  • Old updates merged into snapshots
  • Deleted docs removed after grace period
  • Unused blobs cleaned up
  • Cache expiration policies

Server-Side (PostgreSQL)

-- Simplified schema
CREATE TABLE snapshots (
  workspace_id UUID,
  doc_id TEXT,
  blob BYTEA,          -- Y.js state snapshot
  timestamp BIGINT,
  created_at TIMESTAMP
);

CREATE TABLE updates (
  workspace_id UUID,
  doc_id TEXT,
  seq INTEGER,
  blob BYTEA,          -- Y.js update
  timestamp BIGINT,
  created_at TIMESTAMP
);
Snapshot Squashing:
  • Periodic merging of updates into snapshots
  • Reduces query time for initial loads
  • Old updates archived or deleted
  • Configurable retention policies
// When to squash:
if (updates.length > threshold) {
  // Merge all updates
  const merged = Y.mergeUpdates(updates);
  
  // Save as new snapshot
  await setDocSnapshot({
    docId,
    bin: merged,
    timestamp: Date.now()
  });
  
  // Move old snapshot to history
  await createDocHistory(oldSnapshot);
  
  // Delete squashed updates
  await deleteDocUpdates(updates);
}
Benefits:
  • Fast initial page loads
  • Reduced database size
  • History preservation
  • Better query performance

Multi-Device Sync

Work seamlessly across devices:

Device A → Device B

1. Edit on Device A → Local save + Server upload
2. Server persists → PostgreSQL + Redis pub/sub
3. Device B receives → WebSocket push
4. Device B applies → Y.js merge + IndexedDB save
5. Both devices → Identical state
Sync Guarantees:
  • Eventual consistency: All devices converge to same state
  • Causal ordering: Edits appear in logical order
  • No data loss: All updates preserved and merged

Cross-Device Scenarios

Both devices online:
  • Changes sync in real-time (50-200ms latency)
  • Live cursor positions visible
  • Instant collaborative editing

Performance Optimizations

Client-Side

Lazy Loading

Only load visible documents, not entire workspace

Virtual Scrolling

Render only visible blocks in long documents

Debounced Sync

Batch rapid edits before sending to server

Binary Protocol

Y.js uses efficient binary encoding (not JSON)

Incremental Updates

Only sync changes, not full document

Service Workers

Background sync when app is closed

Server-Side

  • Update compression: Merge multiple updates before broadcast
  • Connection pooling: Reuse database connections
  • Redis caching: Hot documents cached in memory
  • Horizontal scaling: Multiple servers share load via Redis
  • Rate limiting: Prevent abuse and overload

Data Export and Portability

Your data is never locked in:

Export Options

  • Markdown: Plain text export
  • HTML: Web-ready format
  • PDF: Print-ready documents
  • JSON: Structured data export
  • Y.js binary: Full-fidelity backup

Self-Hosting

Run your own AFFiNE server:
# Docker Compose
docker-compose up -d

# Your data stored in:
- PostgreSQL (documents)
- Local filesystem (blobs)
- No external dependencies
Benefits:
  • Full control over data
  • Custom backup strategies
  • On-premise deployment
  • Privacy compliance (GDPR, HIPAA)

Privacy and Security

Local-First = Privacy-First

Client-Side Encryption

(Coming Soon) End-to-end encryption for cloud sync

Zero-Knowledge

Server can’t read your content (with E2EE enabled)

Offline Work

No telemetry or tracking when offline

Self-Hosted

Keep all data on your infrastructure

Data Location

AFFiNE Cloud:
  • Data stored in your chosen region
  • GDPR-compliant EU servers available
  • SOC 2 Type II certified
Self-Hosted:
  • You control data location
  • No data leaves your network
  • Audit logs for compliance

Troubleshooting

Sync Issues

“Not syncing” indicator:
  • Check internet connection
  • Verify WebSocket connection (browser dev tools)
  • Check server status
  • Clear IndexedDB and re-sync (last resort)
Slow sync:
  • Large document may take time to load
  • Check network speed
  • Disable browser extensions
  • Try different network (VPN issues)

Storage Issues

“Quota exceeded” error:
  • Browser storage limit reached (~1-2GB on mobile)
  • Delete unused workspaces
  • Clear cache in settings
  • Use desktop app (no quota limits)
Data corruption:
  • Rare but possible with browser crashes
  • Server has backup (cloud users)
  • Export and re-import workspace
  • Contact support for recovery

Best Practices

  1. Regular sync: Connect to internet periodically to sync changes
  2. Multiple devices: Ensure each device syncs before switching
  3. Backup: Export important workspaces regularly
  4. Storage management: Clean up unused documents
  5. Update app: Keep AFFiNE updated for latest sync improvements
  6. Monitor sync: Watch for “Synced” indicator before closing

Future Enhancements

Peer-to-Peer Sync

Direct device-to-device sync without server

E2E Encryption

Zero-knowledge cloud sync

Selective Sync

Choose which docs to sync locally

Compression

Smaller storage footprint

Next Steps