Local-First Web Architecture: How to Sync IndexedDB with Postgres for Sub-10ms Responsiveness
Learn to sync IndexedDB with Postgres using local-first web architecture. Complete guide to service workers, conflict resolution, and sub-10ms respons

You know that moment when you're working in Linear, Figma, or Notion—you type something and it appears instantly on your screen? No spinner. No waiting. Just pure, instant feedback. Then you see a tiny sync indicator, and a second later the data's in the cloud.
That's local-first architecture. And if you've been building web apps the traditional way (request-response, waiting for the server, showing spinners), you're missing out on one of the most significant shifts in web development happening right now.
The reason these apps feel so responsive isn't magic. It's a deliberate architectural choice: the local state is the source of truth. The network is an implementation detail.
I'm going to walk you through how to build this. Not in theory—in practice. We'll cover the IndexedDB basics, how to keep a Postgres database in sync without losing your mind to conflict resolution, and the service worker patterns that make the whole thing work.
Why Local-First Matters (And Why You Probably Should Care)
For years, we've accepted a broken contract with users: if your internet cuts out, the app is a brick. Dead. You get a spinner and a "check your connection" message. The app can't do anything until the network comes back.
Local-first flips this. The app works. Offline. Completely.
But here's the thing—offline support isn't really the point. It's a side effect. The real win is responsiveness.
Think about what happens in a typical web app right now:
- User types something
- JavaScript runs, updates state
- Network request fires to server
- Server processes it
- Response comes back
- UI updates
That's maybe 100-300ms of latency even on a good connection. For text input, 300ms is forever. Your brain notices. You notice lag. It feels sluggish.
With local-first:
- User types something
- JavaScript writes to IndexedDB
- UI updates immediately from local state
- Background sync sends data to server
- Server processes and confirms
- Conflict resolution kicks in if needed
Step 3 is instant. Sub-10ms. Your nervous system doesn't even perceive it. It feels like a native app because it is a native app. The browser is just hosting it.
The network sync happens in the background. If it fails, it retries. If the user closes the tab, it'll continue retrying the next time they open the app. The user never sees the complexity.
This is why you're seeing every serious SaaS startup moving this direction. It's not a feature. It's the baseline expectation now.
The Local-First Web Stack: What You Actually Need
Let's get concrete. Here's what you're building with:
IndexedDB is your client-side database. It's a transactional, NoSQL database that runs entirely in the browser. You can store megabytes (or even gigabytes depending on the browser). It supports transactions, indexes, and queries. It's async-first and designed for exactly this use case.
Service Workers are the infrastructure layer. They run in a background thread, separate from your main page. They intercept network requests, manage caching, and handle background sync. This is the magic that makes offline work actually work.
Background Sync API lets you register tasks with the service worker that fire as soon as the device has network connectivity. You write data locally, then the service worker wakes up and syncs it to the server automatically—even if the user closed the tab.
Web Locks API prevents race conditions when multiple browser tabs are trying to sync the same data. It's a standard way to say "only one script can touch this resource right now." Seriously underrated API. Most developers don't know it exists.
Postgres on the backend stays the same. You're not replacing your database. You're just adding a sync layer between the browser and the database.
The controversial part is conflict resolution. When two clients edit the same record offline, then both come online, whose changes win? This is where things get spicy. There are basically four approaches:
- Last-write-wins – simple, loses data, acceptable for some use cases
- Server-authoritative – server wins, client changes discarded, fine for most apps
- Queue-based – client queue is flushed in order, works but doesn't handle concurrent edits
- CRDTs – Conflict-Free Replicated Data Types, mathematically guaranteed conflict resolution, but complex
For most B2B SaaS, last-write-wins or server-authoritative is fine. You're not collaborating on the same cell of a spreadsheet at the same time. For real-time collaboration (like Google Docs), you need CRDTs. Libraries like Yjs and Automerge handle this, but there's complexity overhead.
Building the Sync Loop: Architecture That Actually Works
Here's the pattern I've seen work in production:
┌─────────────────────────────────────────────────────────────┐ │ Browser / Client │ ├─────────────────────────────────────────────────────────────┤ │ User Action → React/Vue → IndexedDB Write │ │ ↓ │ │ UI Re-renders Immediately (from local store) │ │ ↓ │ │ Background Sync wakes up (or fires immediately if online) │ │ ↓ │ │ Batch changes into sync request │ │ ↓ │ │ Network Request → Server/Postgres │ ├─────────────────────────────────────────────────────────────┤ │ Backend / Server │ ├─────────────────────────────────────────────────────────────┤ │ Receive sync request │ │ ↓ │ │ Apply changes to Postgres │ │ ↓ │ │ Return changeset (in case of conflicts) │ │ ↓ │ │ Response → Client │ ├─────────────────────────────────────────────────────────────┤ │ Browser / Client │ ├─────────────────────────────────────────────────────────────┤ │ Receive confirmation │ │ ↓ │ │ Merge server changeset (handle conflicts) │ │ ↓ │ │ Update IndexedDB with authoritative server state │ │ ↓ │ │ UI stays consistent (already showed local version) │ └─────────────────────────────────────────────────────────────┘
The key insight: the user never waits for the server. They see local state immediately. The sync is asynchronous and persistent. If the network fails midway, the sync request is queued in IndexedDB and retried when connectivity returns.
Service Workers: Making Background Sync Actually Work
A service worker is basically a JavaScript file that runs in its own thread, outside your page. It can intercept network requests, cache responses, and handle background sync events.
Here's the lifecycle for local-first sync:
Registration:
1// In your main app
2if ('serviceWorker' in navigator) {
3 navigator.serviceWorker.register('/sw.js');
4}Handling sync events in the service worker:
1// In sw.js
2self.addEventListener('sync', (event) => {
3 if (event.tag === 'sync-to-postgres') {
4 event.waitUntil(syncPendingChanges());
5 }
6});
7
8async function syncPendingChanges() {
9 const db = await openIndexedDB();
10 const pendingChanges = await db.getAllPending();
11
12 if (pendingChanges.length === 0) return;
13
14 try {
15 const response = await fetch('/api/sync', {
16 method: 'POST',
17 body: JSON.stringify({ changes: pendingChanges })
18 });
19
20 if (!response.ok) throw new Error('Sync failed');
21
22 const result = await response.json();
23
24 // Remove synced changes from pending queue
25 await db.markAsSynced(pendingChanges.map(c => c.id));
26
27 } catch (error) {
28 // Retry will be triggered by the browser next time network is available
29 throw error;
30 }
31}When the user makes a change offline, you:
- Write it to IndexedDB marked as "pending"
- Register a background sync
- The browser watches for connectivity
- When online, it fires the sync event
- The service worker runs the sync function even if the tab is closed
This is genuinely powerful. The user can write 50 messages offline, close the app, then when they open it later and have connectivity, all 50 sync automatically without them doing anything.
Preventing Race Conditions with Web Locks API
Here's a problem nobody talks about: what happens when multiple tabs of the same app are open and both trying to sync?
This is where Web Locks API saves your sanity.
1async function syncWithLock() {
2 await navigator.locks.request('postgres-sync', async (lock) => {
3 // Only one tab can run this at a time
4 const db = await openIndexedDB();
5 const pending = await db.getAllPending();
6
7 if (pending.length > 0) {
8 // Do the sync
9 await fetch('/api/sync', {
10 method: 'POST',
11 body: JSON.stringify({ changes: pending })
12 });
13
14 await db.markAsSynced(pending.map(c => c.id));
15 }
16 });
17}Without this, you'd have race conditions. Two tabs both think they have pending changes. Both try to sync. Data gets duplicated or lost. Nightmare.
Web Locks ensures only one execution context (tab or service worker) can acquire the lock at a time. Others queue up and wait. Dead simple. Bulletproof.
Conflict Resolution: The Hard Part
This is where most local-first implementations fail or get unnecessarily complex.
The simple approach: last-write-wins with timestamps.
When a change comes from the client, it includes a timestamp. The server compares it with what's in Postgres. If the client's timestamp is newer, the client change wins. Otherwise, the server change wins.
1// Client side
2async function applyChange(recordId, newValue) {
3 await db.put({
4 id: recordId,
5 value: newValue,
6 timestamp: Date.now(), // milliseconds since epoch
7 synced: false
8 });
9}
10
11// Server side (Node.js)
12app.post('/api/sync', (req, res) => {
13 const changes = req.body.changes;
14
15 changes.forEach(async (change) => {
16 const existing = await db.query(
17 'SELECT updated_at FROM records WHERE id = $1',
18 [change.id]
19 );
20
21 if (!existing) {
22 // New record, just insert
23 await db.query(
24 'INSERT INTO records (id, value, updated_at) VALUES ($1, $2, $3)',
25 [change.id, change.value, new Date(change.timestamp)]
26 );
27 } else if (new Date(change.timestamp) > existing.updated_at) {
28 // Client is newer, apply change
29 await db.query(
30 'UPDATE records SET value = $2, updated_at = $3 WHERE id = $1',
31 [change.id, change.value, new Date(change.timestamp)]
32 );
33 }
34 // Otherwise server wins, do nothing
35 });
36
37 res.json({ success: true });
38});This isn't fancy. It's not mathematically perfect. But it's practical and it works for most applications. If you have users occasionally editing the same data offline, last-write-wins is fine. The conflicts are rare enough that the simplicity is worth it.
If you need real-time collaboration on the same object (like multiple people editing a document), that's when you reach for CRDTs like Yjs or Automerge.
IndexedDB Schema Design: Keep It Simple
The mistake most developers make is trying to mirror their Postgres schema exactly in IndexedDB. Don't do that.
IndexedDB is NoSQL. Treat it that way. Store data as objects.
1const dbName = 'MyAppDB';
2const dbVersion = 1;
3
4const request = indexedDB.open(dbName, dbVersion);
5
6request.onupgradeneeded = (event) => {
7 const db = event.target.result;
8
9 // Main data store
10 const objectStore = db.createObjectStore('records', { keyPath: 'id' });
11 objectStore.createIndex('userId', 'userId', { unique: false });
12 objectStore.createIndex('synced', 'synced', { unique: false });
13
14 // Pending changes queue
15 const pendingStore = db.createObjectStore('pending', { keyPath: 'changeId' });
16 pendingStore.createIndex('timestamp', 'timestamp', { unique: false });
17};Then when you sync, you're sending and receiving JSON blobs. The Postgres schema can stay relational. The IndexedDB schema is flat objects. Let them be different. It's fine.
Real-World Gotchas
Storage Quota: IndexedDB has limits (usually 50% of available disk, but it varies). Monitor usage. Show a warning if you're approaching the limit. Use
navigator.storage.estimate()
Cache Invalidation: If you deploy a new version of your app, old service worker caches can serve stale data. Implement cache versioning and cleanup strategies.
Partial Syncs: The network can fail mid-sync. Your backend needs to be idempotent—applying the same change twice should be safe.
Clock Skew: Client clocks aren't reliable. Don't trust
Date.now()
Testing: Offline-first is hard to test. Use Chrome DevTools network throttling and offline mode. Write tests that simulate network failures and recovery.
The Library Ecosystem: What's Worth Using
ElectricSQL – Postgres-specific, real-time sync, read-only client by default (simpler conflict resolution). Good if you want to keep things simple.
PowerSync – Mobile-focused (Flutter, React Native), bidirectional sync, upload queue built in. Excellent if you're doing mobile.
RxDB – Generic local-first database with multiple backends (IndexedDB, SQLite, LevelDB). Integrates with Supabase.
Triplit – Full-stack sync engine, built for local-first from the ground up.
Replicache – Client-side database with Postgres sync. Strong documentation.
For a fresh project in 2025-2026, I'd lean toward ElectricSQL if you want simplicity, or PowerSync if you need mobile and upload queues. Both are production-ready.
If you want full control and don't mind the complexity, raw IndexedDB + service workers + a custom sync layer works fine. It's not that much code.
Why This Matters for Your Users
Local-first architecture isn't about being trendy. It's about UX.
Your app feels snappier. Offline edge cases disappear. Deployment doesn't require downtime (data syncs gradually). Users can open your app in airplane mode and keep working.
Teams that move to local-first report:
- Lower server load – reads are local, writes are batched
- Better perceived performance – instant UI updates
- Fewer support tickets – offline is just "it works"
- Easier scaling – you can rely on edge databases instead of monolithic clusters
The tradeoff? Complexity. Conflict resolution. Testing. Debugging sync issues is harder than debugging request-response.
But if you're building a SaaS product that your users depend on, the tradeoff is worth it.
What's Next
If you're starting a new project, here's what I'd do:
- Start simple – IndexedDB + service worker background sync, last-write-wins conflict resolution
- Get the sync loop working – focus on the happy path first (online sync)
- Add offline support – make sure pending changes persist and retry
- Test edge cases – network failures, clock skew, race conditions
- Optimize later – only add CRDTs or fancier conflict resolution when you have real data loss
Don't overthink it. You don't need a library initially. Learn how the primitives work. Build it yourself once. Then you'll know which library (if any) actually fits your problem.
The future of web apps is local-first. Get ahead of it now.
Most People Asked
CS student and builder writing about tech, startups, AI, and productivity. Built a SaaS that didn't ship — walked away with real product experience instead. Sharing everything learned along the way.

