
The 'Save' Button is a Lie: My Journey into the World of Local-First Syncing
I stopped waiting for 200 OK responses and started building apps that feel like local software, even when the internet disappears.
We’ve been conditioned to wait for a spinning loader every time we click "Save," as if our data doesn't actually exist until a server thousands of miles away acknowledges it. But the moment you stop treating the network as a prerequisite for interaction, your applications start feeling less like a fragile series of requests and more like actual, reliable tools.
The Tyranny of the Spinner
For years, I built apps the "standard" way. User clicks a button, UI shows a loading state, we POST to an API, wait 400ms (or 4 seconds on a subway), and then—finally—tell the user they're allowed to move on.
This is the Request-Response Trap. We’ve offloaded the responsibility of state management to the network. If the WiFi hiccups or the server is having a bad day, the app effectively breaks. The "Save" button isn't a feature; it's a symptom of a design that doesn't trust the device it's running on.
Changing the Mental Model
Local-first architecture flips the script. Instead of the database being the source of truth and your local state being a "cache," the local state *is* the truth. The server becomes a specialized backup and a relay for multi-user synchronization.
In this world, the "Save" button disappears. Every keystroke and click is persisted immediately to a local database (like IndexedDB or SQLite). The sync happens in the background. If you're offline, you keep working. When you're back online, the bits flow to the server.
What this looks like in code
You don't need a massive framework to start thinking this way. The core pattern involves an Optimistic Mutation and a Sync Queue.
Here’s a simplified look at how I handle a task update now. Instead of waiting for a fetch call, I update my local store and queue a background sync job.
// A simplified sync-loop pattern
const taskStore = {
async updateTask(id, updates) {
// 1. Update local database immediately (IndexedDB/SQLite)
await db.tasks.update(id, updates);
// 2. Refresh UI instantly
ui.render();
// 3. Push a "mutation" to the outgoing queue
await db.syncQueue.add({
type: 'UPDATE_TASK',
payload: { id, ...updates },
timestamp: Date.now()
});
// 4. Trigger the background sync (don't 'await' this)
this.processQueue();
}
};
async function processQueue() {
const pending = await db.syncQueue.getAll();
for (const item of pending) {
try {
await api.post('/sync', item);
await db.syncQueue.delete(item.id);
} catch (err) {
// If we're offline, we just stop.
// The data is still safe in IndexedDB.
console.warn("Still offline, retrying later.");
break;
}
}
}The "Oh Crap" Moment: Conflicts
The biggest hurdle people cite with local-first is conflict resolution. If User A and User B both edit the same document while offline, what happens when they reconnect?
You have three main paths here:
1. LWW (Last Write Wins): Simple, but someone's work gets deleted. Fine for "last updated at" fields, terrible for text documents.
2. CRDTs (Conflict-free Replicated Data Types): Math-heavy structures (like those used in Figma or Google Docs) that can merge changes automatically without a central server.
3. Causal Integrity: Using timestamps and version vectors to see which change happened "after" another.
For most of my apps, I started with LWW at the field level. If I change the title and you change the description, we can merge those easily. If we both change the title? I'll take the one with the higher timestamp. It’s not perfect, but it’s 95% of the way there for many B2B tools.
Why go through the trouble?
You might think, "My users are always online, why bother?"
Because "online" is a spectrum. There is "airplane mode" (easy to detect), but there is also "lie-fi"—that state where your phone says you have 5G but the packets are currently dying in a crowded coffee shop.
Local-first apps don't care about lie-fi. They feel instant. There is no "latency" because the disk is faster than the fiber optic cable to AWS. When you remove the wait time, the user's brain stays in "flow" state. They stop treating your app like a website and start treating it like a part of their computer.
The Gotchas
It's not all sunshine. You have to handle:
* Schema Migrations: If you change your DB structure, you have to migrate the data on the user's device, not just your production Postgres instance.
* Storage Limits: Browsers can be stingy with IndexedDB space if the user's hard drive is nearly full.
* Privacy: Since the data is on the device, you need to think about encryption if the device is shared.
Starting Small
You don't have to rewrite your whole stack. Start by making one high-interaction feature "local-first." Use something like Replicache, RxDB, or even just a robust TanStack Query setup with persistQueryClient.
The goal isn't necessarily to work in a cabin in the woods without internet. The goal is to build software that respects the user's time by never making them wait for a green checkmark from a server that might be halfway across the world.
The save button is a lie because the data should have been saved the moment the user thought of it. Stop making them ask for permission to move on.

