Swoff Swoff
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

StrategyWhen to UseImplementation
Last-Write-WinsSingle user, simple dataServer timestamp comparison
Server WinsAuthoritative data (inventory)Always use server version
Per-Field MergeStructured documentsCustom per-field logic
ManualImportant contentStore conflict, prompt user

What's Included

FeatureStatus
Note CRUD
Offline reads
Offline writes
Version tracking
Conflict detection (409)
Per-field merge
Manual conflict UI
Cross-tab sync

Next Steps

On this page