gymtracker is a local-first web app for logging workouts, with cross-device sync and shared sessions for training together. It is built on Automerge, a CRDT library, and its companion automerge-repo, which handles persistence and sync. The app is live at janreitz.com/gymtracker.

Workout logging is a good fit for local-first software: it happens in gym basements with bad reception, the data is personal, and collaboration is occasional rather than constant. There is no backend; the only server involved is a generic sync relay.

This post is less about the app and more about what building on a CRDT changes about application design. Two questions turned out to matter most: what should be a document, and how to change the schema when there is no central database to migrate.

Sync for free

The entire infrastructure setup:

const repo = new Repo({
  network: [new WebSocketClientAdapter('wss://sync.automerge.org')],
  storage: new IndexedDBStorageAdapter(),
});

This buys a lot: documents persist locally in IndexedDB, edits work offline, and changes sync automatically through the relay when a connection is available. There is no sync button and no conflict dialog; concurrent edits merge according to CRDT semantics. Syncing and persistence work flawlessly, as advertised.

The gap is permissions. Documents are stored on a public relay and anyone with a document URL can read and modify it. The URL is the only capability, and there is no obvious way to share a document read-only. Acceptable for workout data among friends, not a general-purpose answer.

What should be a document?

Merging happens within a document, but sharing, loading, and availability are all per-document. Choosing document boundaries is the central modeling decision, comparable to choosing aggregate boundaries in a database, except that every boundary is also a unit of distribution. The data model has to be adjusted to fit the document model; fighting it does not end well.

Gymtracker uses one private root document per user:

interface GymLog {
  schemaVersion: number;
  userId: string;
  displayName: string;
  activeSessionUrl: AutomergeUrl | null;
  editingDocUrl: AutomergeUrl | null;
  completedSessions: AutomergeUrl[];
  createdAt: string;
  sessionTemplates: AutomergeUrl[];
  exerciseLibrary: Record<string, ExerciseDocument>;
  editingExerciseName: string | null;
}

Sessions are separate documents, because they are the unit of sharing. Joining a shared workout means resolving a session URL and adding yourself to its participants record:

const retrievedDocumentHandle = await repo.find<Session>(url);

retrievedDocumentHandle.change((d: Session) => {
  d.participants[gymLog.userId] = {
    displayName: gymLog.displayName,
    joinedAt: new Date().toISOString(),
    leftAt: null,
  };
});

From that point all participants log sets into the same document and see each other's entries live. Each set carries a performedBy field, so a shared session still attributes work to individuals.

In practice, shared sessions go mostly unused, even when training together. Everyone follows their own plan and tracks their own progress; the workout is social, the data isn't. What the sync machinery gets used for instead is per-person, cross-device replication: the same log on phone and laptop, with no account and no backend. Since collaboration and multi-device sync are the same mechanism in Automerge, this required nothing extra.

Session templates illustrate the opposite choice: starting a session from a template copies its exercises by value rather than referencing the template document. Editing a template later must not rewrite the history of sessions created from it. In a local-first system, copy-by-value is often the correct default, because references imply that a document must be loaded, available, and access-controlled wherever it is used.

Shared templates are where this breaks down. Sharing a training plan works mechanically, but participants typically want personal modifications, such as their own weights and set counts, while still following the shared plan. That is a fork-and-merge workflow, and while forking is what CRDTs do internally all the time, Automerge offers no ergonomic model for deliberate, user-visible forks with selective merging. This remains unresolved in gymtracker.

The exercise library detour

The exercise library went through both designs. Exercises carry metadata (aliases, notes, tags) shared across all sessions referencing them, which initially suggested reference semantics: each exercise as its own Automerge document, referenced by URL (schema v3).

In practice this meant that any view touching exercise metadata had to asynchronously load dozens of small documents, each of which could independently be unavailable. Identity became a problem too: nothing prevents two replicas from creating separate documents for "Bench Press", and deduplicating documents is harder than deduplicating entries. The benefits of referencing (independent sharing and lifecycle) were never used, since exercises are only meaningful within one user's log.

Schema v4 reversed the decision: exercises live inline in the root document as a Record<string, ExerciseDocument> keyed by name, with an aliases array per entry. Name collisions are now merges of map entries, handled by Automerge, and lookup is synchronous. The general lesson: a piece of state only earns its own document if it needs independent sharing or an independent lifecycle. Deduplication and shared metadata alone do not justify the distribution cost.

Migrating schemas without a server

The schema went through four versions. With a central database, each version change is a migration script run once. Here, there is no single place where the data lives. Replicas sit in IndexedDB on devices that may not have been online for weeks. Migration has to run client-side, at startup, against whatever state has synced so far:

async function performMigrations(mainDocHandle: DocHandle<GymLog>, repo: Repo): Promise<void> {
  const gymLog = mainDocHandle.doc();
  const currentVersion = gymLog.schemaVersion || 1;

  if (currentVersion === CURRENT_SCHEMA_VERSION) {
    return;
  }

  if (currentVersion > CURRENT_SCHEMA_VERSION) {
    throw new Error('Document schema is newer than app version - this might cause issues');
  }

  for (let version = currentVersion + 1; version <= CURRENT_SCHEMA_VERSION; version++) {
    await migrationFunctionsByVersion[version](mainDocHandle, repo);
  }
}

The interesting consequences are in the failure modes. The v3-to-v4 migration folds separate exercise documents back into the root document, but some of those documents may simply not be present on this replica. The migration cannot wait for them, since they may live on a peer that never comes back online. Instead, unavailable documents get placeholder entries:

const exerciseHandle = await repo.find<ExerciseDocument>(exerciseUrl, {
  allowableStates: ['ready', 'unavailable'],
});

if (exerciseHandle.isReady()) {
  loadedExercises.push({ url: exerciseUrl, doc: exerciseHandle.doc() });
} else {
  loadedExercises.push({ url: exerciseUrl, doc: {
    name: `[Unavailable Exercise ${exerciseUrl.slice(-8)}]`,
    notes: 'This exercise was unavailable during migration',
    tags: ['migrated', 'unavailable'],
    // ...
  }});
}

The same migration also has to resolve name conflicts among the documents it inlines: when several exercise documents share a name, the most recently edited one wins and the others are merged into it as aliases.

The version check at the top points at the remaining unsolved case: mixed client versions. A replica that encounters a document with a newer schema than its own code refuses to proceed, because writing pre-migration structures into a post-migration document would corrupt it on merge. For a single user updating all their devices eventually, this is a tolerable failure mode. For collaboration across users on different app versions, schema evolution in CRDTs is a genuinely open problem: migrations are just edits, and edits from replicas with different notions of the schema merge without regard for either.

From immediate mode to React

The first version was a vanilla TS single-page app with immediate-mode rendering, an approach carried over from C++ and Dear ImGui: the entire UI is derived from Automerge document state and re-rendered on document changes. Document queries happen asynchronously up front; rendering itself is done by synchronous, pure functions that emit DOM elements whose event handlers trigger document changes. No framework, no virtual DOM, and fast enough despite full rerenders.

The appeal of this pattern is that local edits and synced remote changes take the exact same path, and the UI cannot get out of sync with the data because it is derived from nothing else. What broke it was transient UI state. Rendering purely from document state means all state must live in the documents. Coarse-grained state fits there naturally: which document is currently being edited is part of GymLog and conveniently survives reloads and device switches. Focus, scroll position, and open or closed details do not fit, and a full rerender resets them all. Working around this case by case amounts to reimplementing DOM reconciliation, which is the problem React exists to solve. Hence the rewrite: the same data flow, with React preserving transient state between renders. Data entry ergonomics improved in small increments that added up considerably.

Summary

The costs of building on Automerge surface at the edges. Document boundaries determine sharing, loading, and availability, so they deserve the same care as database schema design. Read-only sharing and user-visible forking have no good answers yet, and schema changes mean client-side migrations that must tolerate partial data and version skew. In return, Automerge delivers on its core promise: offline-first persistence and multi-device sync with essentially no infrastructure. After a year of regular use and close to a hundred logged sessions, the local-first bet has held up.