Reference Example
Notes App
Real-time note-taking with conflict resolution for multi-device sync.
Notes App Reference
A collaborative notes app demonstrating conflict resolution when the same note is edited on multiple devices while offline.
What It Demonstrates
- ✅ Create, read, update, delete notes
- ✅ Offline read access (cached)
- ✅ Offline writes (queued)
- ✅ Conflict detection (version stamps)
- ✅ Conflict resolution strategies (LWW, merge, manual)
- ✅ Cross-device sync
Architecture
┌─────────────────────────────────────────────────┐
│ Client (Browser) │
├─────────────────────────────────────────────────┤
│ IndexedDB │
│ ├── notes (local notes) │
│ ├── sync-queue (pending mutations) │
│ └── conflicts (unresolved conflicts) │
├─────────────────────────────────────────────────┤
│ Service Worker │
│ └── notes cache │
└─────────────────────────────────────────────────┘
↓ (when online)
┌─────────────────────────────────────────────────┐
│ Your Backend │
│ └── /api/notes (with version stamps) │
└─────────────────────────────────────────────────┘Conflict Resolution Strategy
The Problem
Device A (offline): Edit note "Buy milk" → title = "Buy groceries"
Device B (offline): Edit note "Buy milk" → title = "Buy milk and eggs"
Both come online:
Server has only one version → whose wins?The Solution (Per-Field Merge)
// Each note has a version stamp from server
interface Note {
id: string;
title: string;
content: string;
$version: number; // Increments on each update
$synced: boolean;
}
// Merge strategy: per-field
const noteMergeStrategies = {
// Title: last writer wins (LWW)
title: (local, server) => server ?? local,
// Content: append if both changed (merge)
content: (local = "", server = "") => {
if (local === server) return local;
if (!local) return server;
if (!server) return local;
return local + "\n--- Merged from server ---\n" + server;
},
// Tags: union of both (no data loss)
tags: (local = [], server = []) => [...new Set([...local, ...server])],
};
function mergeNote(local, server) {
const merged = {};
for (const field of Object.keys(local)) {
if (noteMergeStrategies[field]) {
merged[field] = noteMergeStrategies[field](local[field], server[field]);
} else {
merged[field] = server[field] ?? local[field];
}
}
merged.$version = server.$version;
merged.$synced = true;
merged.$conflict = false;
return merged;
}Key Files
Note Storage (IndexedDB)
// lib/storage/notes.ts
const DB_NAME = "notes-app";
const NOTES_STORE = "notes";
export async function createNote(title, content = "") {
const tempId = `temp-${Date.now()}-${Math.random().toFixed(9).slice(2)}`;
const note = {
id: tempId,
title,
content,
createdAt: Date.now(),
updatedAt: Date.now(),
$version: 0,
$synced: false,
};
const db = await openDB(DB_NAME);
const tx = db.transaction(NOTES_STORE, "readwrite");
tx.objectStore(NOTES_STORE).put(note);
return note;
}
export async function updateNote(id, changes) {
const db = await openDB(DB_NAME);
const tx = db.transaction(NOTES_STORE, "readwrite");
const store = tx.objectStore(NOTES_STORE);
const existing = await new Promise((r) => {
const req = store.get(id);
req.onsuccess = () => r(req.result);
});
if (!existing) throw new Error("Note not found");
const updated = {
...existing,
...changes,
updatedAt: Date.now(),
$synced: false,
$version: existing.$version + 1,
};
store.put(updated);
return updated;
}
export async function getNote(id) {
const db = await openDB(DB_NAME);
return new Promise((r) => {
const tx = db.transaction(NOTES_STORE, "readonly");
const req = tx.objectStore(NOTES_STORE).get(id);
req.onsuccess = () => r(req.result);
});
}
export async function getAllNotes() {
const db = await openDB(DB_NAME);
return new Promise((r) => {
const tx = db.transaction(NOTES_STORE, "readonly");
const req = tx.objectStore(NOTES_STORE).getAll();
req.onsuccess = () => r(req.result.filter(n => !n.$deleted));
});
}Conflict-Aware Mutation Queue
// lib/sync/conflict-resolution.ts
export async function processQueueWithConflict() {
const queue = await getMutationQueue();
for (const item of queue) {
try {
const response = await fetch(item.url, {
method: item.method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(item.body),
});
if (response.status === 409) {
// CONFLICT detected
const serverState = await response.json();
await handleConflict(item, serverState);
} else if (response.ok) {
const serverData = await response.json();
await reconcileRecord(item.storeName, item.tempId, serverData);
await invalidateByTags(item.tags);
await removeFromQueue(item.id);
}
} catch (error) {
// Handle network errors
}
}
}
async function handleConflict(item, serverState) {
const local = await getRecord(item.storeName, item.tempId);
const merged = mergeNote(local, serverState);
// Check if automatic resolution is possible
const hasConflict = Object.keys(local).some(
key => key.startsWith("$") || JSON.stringify(local[key]) !== JSON.stringify(serverState[key])
);
if (hasConflict && !canAutoMerge(local, serverState)) {
// Store for manual resolution
await storeConflict(item, local, serverState);
window.dispatchEvent(new CustomEvent("conflict-detected", {
detail: { noteId: item.tempId }
}));
} else {
// Auto-merge succeeded
await putRecord(item.storeName, merged);
await removeFromQueue(item.id);
}
}
function canAutoMerge(local, server) {
// If only one field changed, auto-merge is safe
const changed = Object.keys(local).filter(
key => !key.startsWith("$") && local[key] !== server[key]
);
return changed.length <= 1;
}Notes UI with Conflict UI
// pages/Notes.tsx
import { useState, useEffect } from "react";
import { getAllNotes, updateNote, createNote, deleteNote } from "../lib/storage/notes";
import { queueMutation } from "../swoff/mutation-queue";
export function NotesPage() {
const [notes, setNotes] = useState([]);
const [conflicts, setConflicts] = useState([]);
useEffect(() => {
loadNotes();
window.addEventListener("conflict-detected", (e) => {
setConflicts(c => [...c, e.detail.noteId]);
});
}, []);
async function loadNotes() {
const all = await getAllNotes();
setNotes(all.sort((a, b) => b.updatedAt - a.updatedAt));
}
async function handleSave(noteId, title, content) {
const updated = await updateNote(noteId, { title, content });
// Queue for sync
await queueMutation({
method: "PUT",
url: `/api/notes/${noteId}`,
body: { title, content, $version: updated.$version },
tags: ["notes", `note:${noteId}`],
storeName: "notes",
tempId: noteId,
previousData: notes.find(n => n.id === noteId),
});
loadNotes();
}
async function handleDelete(noteId) {
await deleteNote(noteId);
await queueMutation({
method: "DELETE",
url: `/api/notes/${noteId}`,
body: null,
tags: ["notes"],
storeName: "notes",
tempId: noteId,
});
loadNotes();
}
return (
<div>
<h1>Notes</h1>
{conflicts.length > 0 && (
<div role="alert" className="conflict-banner">
{conflicts.length} note(s) have sync conflicts.{" "}
<button onClick={() => showConflictDialog()}>Resolve</button>
</div>
)}
{notes.map(note => (
<NoteCard
key={note.id}
note={note}
onSave={handleSave}
onDelete={handleDelete}
hasConflict={conflicts.includes(note.id)}
/>
))}
</div>
);
}
function NoteCard({ note, onSave, onDelete, hasConflict }) {
const [editing, setEditing] = useState(false);
const [title, setTitle] = useState(note.title);
const [content, setContent] = useState(note.content);
return (
<div className={hasConflict ? "border-red-500" : ""}>
<input
value={title}
onChange={e => setTitle(e.target.value)}
disabled={!editing}
/>
<textarea
value={content}
onChange={e => setContent(e.target.value)}
disabled={!editing}
/>
{note.$synced ? (
<span aria-label="Synced">✓</span>
) : (
<span aria-label="Pending sync">⏳</span>
)}
{hasConflict && <span aria-label="Has conflict">⚠️</span>}
</div>
);
}Conflict Resolution Options
| Strategy | When to Use | Implementation |
|---|---|---|
| Last-Write-Wins | Single user, simple data | Server timestamp comparison |
| Server Wins | Authoritative data (inventory) | Always use server version |
| Per-Field Merge | Structured documents | Custom per-field logic |
| Manual | Important content | Store conflict, prompt user |
What's Included
| Feature | Status |
|---|---|
| Note CRUD | ✅ |
| Offline reads | ✅ |
| Offline writes | ✅ |
| Version tracking | ✅ |
| Conflict detection (409) | ✅ |
| Per-field merge | ✅ |
| Manual conflict UI | ✅ |
| Cross-tab sync | ✅ |
Next Steps
- Conflict Resolution — full strategy guide
- Mutation Queuing — offline write handling
Swoff