CRDTs and Local-First Architecture: How smallstack Handles Offline Conflict Resolution
Most web apps treat the network as a prerequisite. No connection? You get a spinner, an error banner, or worse — silent data loss. At smallstack, we made a different choice from the start: local-first.
This post breaks down what that means technically, how we use CRDTs and SignalDB to handle conflict resolution, and why this architecture matters for building serious business software.
The Problem with "Offline Mode"
"Offline mode" is a spectrum. At the bad end: you cache the last-loaded page and show a banner saying "you're offline, changes won't be saved." At the slightly better end: you queue writes locally and flush them when connectivity returns — but without any conflict handling, so the last writer wins (silently destroying the other's work).
Neither is good enough for field teams who genuinely work disconnected for hours at a time.
Local-first inverts the model:
- The local device is the primary data store, not a cache.
- Reads are instant — no network roundtrip.
- Writes are committed locally first, then synced.
- The server's job is replication between peers, not the source of truth.
This changes the UX fundamentally: no loading spinners, no "connection lost" interruptions, no perceived latency. But it requires solving a hard problem — what happens when two users modify the same record while offline?
Enter CRDTs
CRDT stands for Conflict-free Replicated Data Type. A CRDT is a data structure where concurrent modifications from multiple sources can always be merged into a consistent result without coordination — no locks, no conflict resolution UI, no data loss.
The key insight: instead of storing "the current value," CRDTs store operations or state vectors that can be merged in any order and always produce the same result (a property called commutativity and associativity).
A practical example
Two field technicians, Anna and Ben, are offline:
- Anna updates the status of Work Order #42 to
in_progress. - Ben adds a note to Work Order #42: "Need replacement part."
When both sync:
- Two independent operations on the same record.
- Both changes are present in the merged result.
- No conflict.
What if both update the same field — say, the status field?
smallstack uses Last-Write-Wins (LWW) semantics for scalar fields, with a logical timestamp (not wall clock). The operation with the higher timestamp wins. For collaborative text editing or list structures, LWW doesn't cut it — and that's where proper CRDT types (like Logoot or RGA for sequences) come in.
SignalDB: Incremental Sync in Practice
smallstack uses SignalDB — a reactive, local-first database for the browser built with TypeScript. It's the layer between our SvelteKit frontend and the MongoDB backend.
The sync protocol works like this:
Client → Server: "Give me everything updated after timestamp T"
Server → Client: [delta of changed documents]
Client: Merge deltas into local collection
Notify reactive subscribers ($state runes update automatically)
Every document in SignalDB has an updatedAt field. On reconnect, the client sends its latest known timestamp, and the server returns only what's changed since then — not the full dataset. This makes initial sync fast and incremental updates tiny.
// Simplified example of how sync works under the hood
collection.sync({
pull: async (lastPulledAt) => {
const changes = await fetch(`/api/orders?updatedAfter=${lastPulledAt}`);
return changes.json(); // Only the delta
},
push: async (changes) => {
await fetch('/api/orders', {
method: 'POST',
body: JSON.stringify(changes)
});
}
});
Svelte 5 runes make this reactive binding seamless:
// In a .svelte.ts service file
class OrderService {
orders = $state<Order[]>([]);
constructor() {
// SignalDB collection drives $state reactively
collection.find().onChange((result) => {
this.orders = result;
});
}
}
Every time the sync delivers new data, orders updates automatically, and all components using it re-render. No manual refresh, no writable() stores — just reactive local state that happens to be backed by a synced collection.
Real-Time on Top of Local-First
Local-first doesn't mean "eventually consistent and slow." smallstack also supports real-time push from the server using server-sent events. When a user makes a change:
- It's committed locally (instant, no network wait).
- It's pushed to the server asynchronously.
- The server broadcasts the change to other connected clients.
- Those clients merge the incoming delta into their local store.
- Their reactive state updates.
From the perspective of a connected user, it feels like Google Docs — changes appear immediately. From the perspective of a disconnected user, nothing breaks — changes queue locally and sync when reconnected.
What This Means for the Architecture
Building local-first requires discipline:
No server-side rendering for user data. If data lives in the client's SignalDB, SSR can't hydrate it. We SSR only public, non-personalized content. Authenticated app views are entirely client-rendered.
Schema must travel with data. When a user defines a custom data type in smallstack (our no-code type system), that schema has to be available locally for validation and rendering — even offline.
Migrations are tricky. If the server schema changes and a client is offline, we need to handle schema version mismatches gracefully on sync. We version migrations and apply them lazily on the client.
Conflict policy must be explicit. For every field type, we define the merge strategy upfront: LWW for most scalar fields, custom merge for structured types like address objects, append-only for logs and history.
Why Most No-Code Platforms Can't Do This
Building local-first isn't a feature you can add later. It requires rethinking the data flow from day one:
- Central-server tools (Airtable, Monday, Notion) make the server the source of truth. Offline is an afterthought — literally bolted on after the fact.
- Adding CRDT-based sync to an existing server-centric codebase means rebuilding the entire persistence and sync layer.
We made the local-first decision before writing the first line of product code. That's why it actually works — on construction sites, in basements, on planes, in tunnels.
Try It
If you're building business software that needs to work in environments with unreliable connectivity, the local-first approach is worth the investment. SignalDB is open source and well-documented — a good starting point.
smallstack is the no-code platform built on this foundation. If you want to see what it looks like to give non-technical users a reliable, offline-capable business app — check out the docs or try the free tier.
Questions about the architecture? Drop them in the comments — happy to go deeper on any part of this.
Top comments (0)