Web DevelopmentJune 17, 2026

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

Local-First Web Architecture: How to Sync IndexedDB with Postgres for Sub-10ms Responsiveness

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:

  1. User types something
  2. JavaScript runs, updates state
  3. Network request fires to server
  4. Server processes it
  5. Response comes back
  6. 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:

  1. User types something
  2. JavaScript writes to IndexedDB
  3. UI updates immediately from local state
  4. Background sync sends data to server
  5. Server processes and confirms
  6. 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:

  1. Last-write-wins – simple, loses data, acceptable for some use cases
  2. Server-authoritative – server wins, client changes discarded, fine for most apps
  3. Queue-based – client queue is flushed in order, works but doesn't handle concurrent edits
  4. 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:

text
┌─────────────────────────────────────────────────────────────┐
│                     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:

javascript
1// In your main app
2if ('serviceWorker' in navigator) {
3  navigator.serviceWorker.register('/sw.js');
4}

Handling sync events in the service worker:

javascript
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:

  1. Write it to IndexedDB marked as "pending"
  2. Register a background sync
  3. The browser watches for connectivity
  4. When online, it fires the sync event
  5. 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.

javascript
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.

javascript
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.

javascript
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

text
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

text
Date.now()
for ordering without server confirmation. Use server-generated timestamps for the source of truth.

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:

  1. Start simple – IndexedDB + service worker background sync, last-write-wins conflict resolution
  2. Get the sync loop working – focus on the happy path first (online sync)
  3. Add offline support – make sure pending changes persist and retry
  4. Test edge cases – network failures, clock skew, race conditions
  5. 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

A: IndexedDB and localStorage solve different problems. LocalStorage is a simple key-value store limited to ~5-10MB per domain, and all operations are synchronous (blocking). It's fine for storing user preferences or small configuration, but it'll freeze your UI if you try to store or retrieve larger datasets.

IndexedDB is a proper transactional database designed for offline-first. It supports hundreds of megabytes (or even gigabytes depending on browser), runs asynchronously (non-blocking), and provides indexing, transactions, and complex queries. Think of localStorage as a notepad and IndexedDB as a full SQL database in your browser.

For any serious offline-first app, IndexedDB is the right choice. Use localStorage only for small, infrequently-accessed data like auth tokens or theme preferences.

A: It depends on your use case. Most SaaS apps don't need CRDTs.

Use last-write-wins (simple timestamps) if: Your users aren't editing the exact same record at the exact same time offline. Two tabs syncing, or different users editing different records, works fine with simple conflict resolution. This is your typical B2B SaaS (CRM, task management, projects, etc.). Last-write-wins is dead simple to implement and maintains data integrity without complexity.

Use CRDTs (Yjs, Automerge) if: Multiple users are editing the same document concurrently (Google Docs style, Figma, collaborative text editors). CRDTs guarantee conflict-free merging mathematically. The tradeoff is complexity—CRDTs add metadata overhead (2-3x larger documents) and have a learning curve.

If you're unsure, start with simple last-write-wins conflict resolution. Upgrade to CRDTs only when you have real data loss in production from concurrent edits.

A: The Background Sync API lets you register a sync task with the service worker. When the user makes a change offline:

  1. You write data to IndexedDB marked as "pending"
  2. You register a background sync event with a tag (e.g., sync-to-postgres)
  3. The browser watches for network connectivity
  4. When online, the browser wakes up the service worker and fires the sync event
  5. Your sync function reads pending changes, sends them to the server, and marks them as synced

The magic part: this works even if the user closes the tab or browser. The browser remembers the pending sync request. The next time the browser has connectivity (could be hours later, on a different tab), it fires the sync event and retries. The user never has to do anything.

Browser support caveat: Background Sync API is only supported in Chromium-based browsers (Chrome, Edge, Opera) as of early 2026. Safari doesn't support it. For Safari, you need a fallback: listen to the online event and flush pending changes when the user returns or the tab becomes visible again.

A: This is where the Web Locks API comes in. Without it, two tabs can both try to sync the same data simultaneously, causing duplicate writes or data loss.

The solution is straightforward. Wrap your sync logic in a Web Locks request:

await navigator.locks.request('postgres-sync', async (lock) => {
  // Only one tab can run this at a time
  const pending = await db.getAllPending();
  if (pending.length > 0) {
    await fetch('/api/sync', {
      method: 'POST',
      body: JSON.stringify({ changes: pending })
    });
    await db.markAsSynced(pending.map(c => c.id));
  }
});

When the first tab acquires the lock, other tabs queue up and wait. Once the first tab releases the lock (function completes), the next tab runs. This guarantees only one sync happens at a time across all tabs.

Web Locks API is supported in all modern browsers (Chrome, Firefox, Safari, Edge as of 2025). It's genuinely underrated—most developers don't know it exists, but it's bulletproof for preventing race conditions.

A: This is the conflict resolution problem. In local-first architecture, conflicts are expected and normal, not exceptional.

The standard pattern is last-write-wins with timestamps. When a client change arrives at the server, the server checks: "Is this client's timestamp newer than what I have?" If yes, accept the change. If no, reject it. This is simple and works for most apps because conflicts are rare—different users editing different records.

However, this approach has a limitation: if two users edit the same record offline, one person's change will be lost. For most B2B SaaS, this is acceptable. Users aren't simultaneously editing the same cell of a spreadsheet. But if this is a real scenario for you (collaborative editing), you need CRDTs.

An alternative: server-authoritative resolution. When conflicts happen, the server always wins. Client changes are discarded and the server's version becomes the source of truth. This prevents data loss but feels jarring to users—their change disappeared and was overwritten by someone else.

The best approach for your app depends on your data model and user expectations. Think through: "What should happen when two users edit the same record offline?" Your answer determines your conflict strategy.

A: IndexedDB quotas vary by browser, but modern browsers are generous. Chrome and Firefox typically allow 50% of available disk space (so if you have 100GB free, IndexedDB gets 50GB). Safari is more restrictive—around 50MB per origin by default, but users can grant permission for more.

The important caveat: storage is origin-specific. Each domain/origin has its own quota, so example.com and app.example.com have separate limits.

What happens when you exceed the quota? Writes fail. You get a QuotaExceededError. The browser doesn't auto-clean; your app needs to handle it gracefully.

Best practices:

  1. Monitor usage with navigator.storage.estimate() and warn users before hitting limits
  2. Implement data cleanup strategies (archive old records, delete expired data)
  3. Don't assume unlimited storage—consider pagination or partial replication (sync only the data the user needs)
  4. Test quota behavior locally: Chrome DevTools → Application → Storage → clear site data and simulate low-quota scenarios

Also note: Safari has a 7-day eviction policy. If a user doesn't visit your site for 7 days, Safari might clear IndexedDB automatically. Call navigator.storage.persist() to request persistent storage (user must grant permission) to avoid this.


Q: Should I use IndexedDB or SQLite in the browser?

A: SQLite via WebAssembly is newer and more powerful (full relational queries, better performance for complex data), but requires WASM support and is more complex to persist properly. IndexedDB is mature, works everywhere, and is sufficient for most apps. Start with IndexedDB. Switch to SQLite if you have complex queries or performance issues.

Q: Do I need a library like PowerSync or ElectricSQL, or can I build the sync layer myself?

A: You can build it yourself, and learning the principles is valuable. A custom sync layer is fine for simple cases (CRUD operations, basic conflict resolution). But libraries handle edge cases and production complexity you might miss: idempotency, partial syncs, schema migrations, clock skew, storage quota. For production apps, a library saves months of debugging. For learning or very simple apps, building it yourself is educational.

Q: What's the performance impact of local-first? Will my app be slower?

A: No—actually faster. Local reads (IndexedDB) are instant, no network latency. Writes are instant (local), then sync in the background. Users perceive sub-10ms response time instead of 100-300ms. The tradeoff is complexity: more moving parts to debug and more edge cases to test. But in terms of perceived performance, local-first is objectively faster.

Tags:
local-firstIndexedDBPostgresoffline-firstservice-workersweb-architectureconflict-resolutionbackend-syncprogressive-web-appsdatabase-sync
← View all articles
M
ManickavasaganAuthor

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.