Dial-In
Dial-In is a coffee brewing journal — bags, brews, grind settings, extraction times — built local-first: the app is fully functional from the first visit with no account and no network, and gains multi-device sync when a user signs in. Its instructive decision is the sync protocol. Dial-In syncs with a custom last-write-wins scheme over IndexedDB, not a CRDT framework, and the reasoning behind that trade is the case study.
SFI project: 2025–2026, currently dormant with revival queued; running at dial-in.fitz.gg pending migration to dial-in.coffee. Snippets are lifted from
src/lib/storage/andsrc/routes/api/sync/at current HEAD.
Why local-first
Dialing in an espresso shot happens standing at the machine, phone in hand, often in a kitchen with bad Wi-Fi. A round-trip per interaction is the wrong architecture for that moment. Local-first inverts the default: IndexedDB is the primary store, every read and write is local and instant, and the server is a replication target rather than the source of truth. The SvelteKit UI behaves like an installed app because, functionally, it is one.
The commercial angle follows the same shape: the free product is the local app, complete and unrestricted. What a subscription buys is replication — sync, backup, and multi-device continuity. That makes the paywall a storage boundary rather than a feature gate, which is both easier to implement honestly and easier to explain.
The sync metadata
Every record carries its replication state alongside its data:
export interface SyncMetadata {
deviceId: string;
syncedAt: Date | null;
deletedAt: Date | null;
isDirty: boolean; // True if modified since last sync
localUserId: string | null;
}
export interface CoffeeBrew extends SyncMetadata {
id: string;
coffeeBagId: string;
grindSetting: number;
dryWeight: number;
brewTime: number;
pressureReading: number;
// ...
}
Three fields do the protocol's work. isDirty marks records modified since
the last successful push. deletedAt implements soft deletes — a deleted
record must survive locally as a tombstone until the server acknowledges it,
otherwise a delete on one device resurrects on the next pull to another.
deviceId (a UUID minted per browser) attributes every write, which matters
for debugging sync disputes more than for resolving them.
Push-dirty, pull-since
The protocol is two endpoints and no server-held session state:
async pull(): Promise<{ bags: number; brews: number; deleted: number }> {
const lastSync = getLastSyncTime();
const url = lastSync
? `/api/sync/pull?since=${lastSync}` // incremental: changes only
: '/api/sync/pull'; // first sync: everything
// ...
}
async push(): Promise<{ bags: number; brews: number; deleted: number }> {
const deviceId = getDeviceId();
// dirty edits and dirty tombstones travel together
const allDirtyBags = [...coffeeBagStore.getDirtyItems(),
...coffeeBagStore.getDeletedDirtyItems()];
// ...
const response = await fetch('/api/sync/push', {
method: 'POST',
body: JSON.stringify({ deviceId, coffeeBags, coffeeBrews }),
});
}
The server applies pushes as a single transaction of batch upserts, stamps
syncedAt, and returns serverTime — which the client stores as its next
since cursor, so clock skew between devices never enters the protocol.
Conflict resolution is last-write-wins on updatedAt: the most recent edit
to a record replaces the older one, whole-record.
Sync triggers are deliberately mundane: a 30-second interval, network reconnect, and tab-visibility change. No websockets, no push channel. For a journal, "synced within half a minute of looking at it" is indistinguishable from real-time.
Why not CRDTs
CRDTs solve concurrent-edit merging without a coordinator — and bring per-field metadata, tombstone growth, and a library dependency that shapes your whole data model. Dial-In's writes are one person logging their own brews. Two devices editing the same record in the same sync window is rare, and when it happens, "the newer edit wins, whole-record" is what the user expects anyway. The failure mode LWW accepts — a lost concurrent edit — is bounded to one brew's fields.
The honest statement of the trade: this protocol is correct for single-writer-per-account data and wrong for collaborative data. If Dial-In ever grew shared journals, the sync layer would need redesign, not tuning. Reaching for the simple protocol was still right; carrying CRDT machinery for years against a possibility is how codebases calcify.
Server-side gating
Sync endpoints authenticate the session and check the subscription before touching data:
export const GET: RequestHandler = async ({ locals, url }) => {
if (!locals.user) throw error(401, 'Unauthorized');
const sub = await getSubscription(locals.user.id);
if (sub?.status !== 'active') throw error(403, 'Active subscription required');
// ... incremental or full sync
};
Sessions come from better-auth (authentication-authorization), the subscription state from Stripe webhooks (stripe). Photos attach to bags and brews through the presigned-upload pattern documented in s3-image-uploads.
Things that go wrong
Two failure modes shaped the tombstone handling. First, purging a deleted
record before the server acknowledges the delete resurrects it on the next
pull — hence tombstones are dirty items and travel through push() like any
edit. Second, using client clocks for the since cursor breaks silently when
a device's clock drifts; returning serverTime from every response and
using only that closes the hole. Both bugs are invisible with one device and
inevitable with two — local-first systems need multi-device testing from the
first sync feature, not the last.
References
- Local-first software — the Ink & Switch essay that named the architecture
- IndexedDB API and the idb wrapper
- Related: authentication-authorization, storage, stripe, s3-image-uploads, sveltekit