Your users go offline. Your data stays consistent. Here's how.
Building an app that works offline is easy. Building an app that syncs correctly after being offline on multiple devices? That's where things get interesting.
User edits on their phone (offline). User edits on their laptop (also offline). Both come online. Now what?
The traditional answer: "last write wins" and pray. The better answer: CRDTs.
CRDT stands for Conflict-free Replicated Data Type. It's a data structure designed so that concurrent edits from multiple sources can always be merged automatically, without conflicts.
The key insight: instead of trying to detect and resolve conflicts, design your data so conflicts are mathematically impossible.
Different CRDT strategies work for different data types:
| Strategy | Use Case | Example |
|---|---|---|
| Last-Write-Wins | General fields | Username, email |
| Max | High scores | Game leaderboards |
| Min | Best times | Speedrun records |
| Counter | Incrementing values | Coin collection, visits |
| Set Union | Collections | Tags, achievements |
I was building a game with offline support. Players collect coins, unlock achievements, and track high scores across devices.
Simple sync breaks immediately:
Phone (offline): coins = 100, adds 50 → coins = 150
Laptop (offline): coins = 100, adds 30 → coins = 130
Both sync: Last write wins → coins = 130 (lost 50 coins!)
The player lost 50 coins. They're not happy.
I built keyv-crdt - a CRDT wrapper for the Keyv key-value store that handles all merge strategies automatically.
import KeyvCrdt from "keyv-crdt";
const store = new KeyvCrdt({
deviceId: "phone-123",
stores: [memoryStore, diskStore, cloudStore],
strategies: {
coins: "counter", // Sums contributions from all devices
highScore: "max", // Keeps the highest value
bestTime: "min", // Keeps the lowest value
username: "lww", // Last-Write-Wins for simple fields
achievements: "union", // Merges arrays without duplicates
},
});
// Now operations are conflict-free
await store.increment("coins", 50); // Phone adds 50
// Meanwhile, laptop adds 30
// After sync: coins = 180 (100 + 50 + 30) ✓
Each field stores metadata alongside its value:
type CRDTField<T> = {
v: T; // The value
t: number; // Timestamp (for LWW)
d: string; // Device ID (for tiebreaking)
c?: CounterValue; // Per-device counters (for counter strategy)
};
// Counter strategy stores contributions per device
type CounterValue = Record<string, number>;
// Example: { "phone-123": 50, "laptop-456": 30 }
Last-Write-Wins (lww)
// Compare timestamps, use deviceId as tiebreaker
if (remote.t > local.t ||
(remote.t === local.t && remote.d > local.d)) {
return remote;
}
return local;
Max
return local.v > remote.v ? local : remote;
Min
return local.v < remote.v ? local : remote;
Counter (the clever one)
// Merge per-device contributions, then sum
const merged = { ...local.c, ...remote.c };
for (const device of Object.keys(remote.c)) {
merged[device] = Math.max(
local.c[device] ?? 0,
remote.c[device] ?? 0
);
}
return { v: sum(Object.values(merged)), c: merged };
Union
return [...new Set([...local.v, ...remote.v])];
keyv-crdt uses a cache hierarchy:
┌─────────────┐
│ Memory │ ← Fastest, volatile
├─────────────┤
│ Disk │ ← Persistent, local
├─────────────┤
│ Network │ ← Source of truth, slow
└─────────────┘
On read:
On write:
Deletes are tricky in distributed systems. If you just remove a key, how do other devices know it was intentionally deleted vs. never received?
keyv-crdt uses tombstones: deleted keys are marked with a deletion timestamp, not removed.
// Delete marks the key as deleted at timestamp T
await store.delete("oldAchievement");
// Internally: { v: null, t: T, deleted: true }
// If another device edits before T, the edit wins
// If another device edits after T, the deletion wins
const saveData = new KeyvCrdt({
deviceId: getDeviceId(),
stores: [
new Keyv(), // Memory
new Keyv({ store: indexedDB }), // Browser storage
new Keyv({ store: cloudSync }), // Your backend
],
strategies: {
level: "max",
coins: "counter",
gems: "counter",
unlockedItems: "union",
settings: "lww",
},
});
// Player collects coins
await saveData.increment("coins", 10);
// Player unlocks an item
await saveData.union("unlockedItems", "sword_of_fire");
// Sync happens automatically on read/write
const notes = new KeyvCrdt({
strategies: {
title: "lww",
content: "lww", // Or use a text CRDT for rich merging
tags: "union",
editCount: "counter",
},
});
const prefs = new KeyvCrdt({
strategies: {
theme: "lww",
fontSize: "lww",
visitedPages: "union",
totalVisits: "counter",
},
});
For complex cases, define your own merge logic:
const store = new KeyvCrdt({
strategies: {
complexField: (local, remote, localMeta, remoteMeta) => {
// Your custom merge logic
// Return the merged value
if (local.priority > remote.priority) {
return local;
}
return { ...local, ...remote };
},
},
});
npm install keyv-crdt keyv
import Keyv from "keyv";
import KeyvCrdt from "keyv-crdt";
const store = new KeyvCrdt({
deviceId: crypto.randomUUID(),
stores: [new Keyv()],
strategies: {
// Define your fields
},
});
Good fit:
Not ideal:
Distributed systems are hard. Network partitions happen. Devices go offline.
Instead of fighting this reality with complex conflict resolution UIs ("Which version do you want to keep?"), design your data to merge automatically.
CRDTs shift the complexity from runtime conflict resolution to upfront data modeling. Choose the right strategy for each field, and conflicts become impossible.
Install: npm install keyv-crdt
GitHub: github.com/snomiao/keyv-crdt
Related: keyv-nest for multi-tier caching
Snowstar Miao builds tools for offline-first development. Part of the Keyv ecosystem series.