Offline-First Conflict Resolution
Understanding and implementing intelligent conflict resolution for multi-client offline-first synchronization
Offline-First Conflict Resolution
🎯 The Problem
Your application is now correctly showing the same space across browsers, but with different data. This is the expected behavior of offline-first architecture when multiple clients work independently!
What's Happening
Browser A (Chrome):
└─ Space: 69067c087ebb621cd9ff8e3f
├─ Universe: new-pakos-uni-1
└─ Universe: new-pakos-uni-2
Browser B (Firefox):
└─ Space: 69067c087ebb621cd9ff8e3f
└─ Universe: browser-uni-paks-1
Both browsers created local data while offline. Now that sync is enabled, they're fighting over which data is "correct"!
🔍 Root Causes
1. No Pull Implementation
The pullFromBackend() method in SyncEngineService is currently a stub:
private async pullFromBackend(): Promise<void> {
const lastSync = this.lastSyncTime();
this.logger.info('[SyncEngine] 📥 Pulling changes from backend', {
since: new Date(lastSync).toISOString()
});
// TODO: Implement change detection API
// For now, skip pull to avoid conflicts during initial implementation
this.logger.debug('[SyncEngine] Pull from backend not implemented yet');
}
Impact: The application only pushes local changes to the backend. It never pulls the backend's state to merge with local data.
2. No Conflict Resolution
When pushing updates, the current implementation uses "last write wins":
} else if (operation.operation === 'update' && operation.data) {
// Extract spaceId from key (format: space_${ownerId})
const spaceId = operation.key.replace('space_', '');
const updateRequest = {
name: operation.data.name,
description: operation.data.description,
spaceSlug: operation.data.spaceSlug,
universeIds: operation.data.universeIds || [], // ✅ CRITICAL: Include universeIds
settings: operation.data.settings
};
await firstValueFrom(this.spaceApi.updateSpace(spaceId, updateRequest));
this.logger.info('[SyncEngine] ✅ Space synced to backend:', {
spaceId,
universeIdsCount: updateRequest.universeIds.length
});
}
Impact: Entire universeIds array is replaced. If Browser A pushes [uni-1, uni-2] and then Browser B pushes [uni-3], the backend ends up with only [uni-3] instead of merging to [uni-1, uni-2, uni-3].
3. Corrupted Queue Data
The console errors show:
⚠️ Unknown entity type: undefined - skipping sync
operation: undefined, entity: undefined, key: undefined
This is corrupted data in the persisted sync queue from before sync was enabled.
🎯 Solution Strategy
✅ Automatic Self-Healing (NO USER ACTION REQUIRED)
The system now automatically detects and removes corrupted data on startup and during sync operations. Users never need to manually clear anything!
Auto-Cleanup Features:
- On App Initialization: Scans sync queue and removes corrupted operations
- During Sync: Skips and removes any corrupted operations encountered
- Fallback Safety: If cleanup fails, clears entire queue as safety measure
Result: Users see clean console, system self-heals, no manual intervention needed! 🎉
Phase 1: Clear Corrupted Data AUTOMATIC ✅
1. Clear sync queue in BOTH browsers2. Clear IndexedDB in BOTH browsers3. Reload the application
UPDATE: This is handled automatically by the system! No user action required.
For Developers Only (if you have existing corrupted data from testing):
- Just reload the app - auto-cleanup will fix it
- If you want to start completely fresh: Clear browser data manually
Phase 2: Implement Smart Pull (IMPLEMENTED ✅)
The sync engine needs to:
- Fetch backend state before pushing local changes
- Detect conflicts by comparing timestamps/versions
- Merge arrays intelligently (union, not replace)
- Preserve user intent (don't lose data)
Phase 3: Implement Conflict Resolution Strategies
Different data types need different strategies:
| Data Type | Strategy | Example |
|---|---|---|
| Scalar (name, description) | Last-write-wins with timestamp | Newer timestamp wins |
| Arrays (universeIds) | Union merge | [1,2] + [2,3] = [1,2,3] |
| Objects (settings) | Deep merge | Combine properties |
| Deletions | Tombstones | Track deleted items |
🛠️ Implementation Plan
Step 1: Add Pull Implementation
private async pullFromBackend(): Promise<void> {
try {
// Get user's current space from backend
const userId = await this.getCurrentUserId();
const backendSpace = await firstValueFrom(
this.spaceApi.getUserSpace(userId)
);
if (!backendSpace) {
return; // No backend data yet
}
// Get local space
const localSpace = await this.read<Space>(`space_${userId}`);
if (!localSpace) {
// No local data, just write backend data
await this.storage.set(`space_${userId}`, backendSpace);
return;
}
// MERGE LOCAL + BACKEND
const mergedSpace = this.mergeSpaceData(localSpace, backendSpace);
// Write merged data locally
await this.storage.set(`space_${userId}`, mergedSpace);
this.logger.info('[SyncEngine] ✅ Pulled and merged from backend');
} catch (error) {
this.logger.error('[SyncEngine] Failed to pull from backend:', error);
}
}
Step 2: Add Intelligent Merging
private mergeSpaceData(local: Space, remote: Space): Space {
return {
...remote, // Start with remote (newer metadata)
// Merge arrays (union, preserve unique)
universeIds: Array.from(new Set([
...(local.universeIds || []),
...(remote.universeIds || [])
])),
// Use local name if modified more recently
name: local.updatedAt > remote.updatedAt ? local.name : remote.name,
// Deep merge settings
settings: {
...remote.settings,
...local.settings // Local settings take precedence
}
};
}
Step 3: Add Timestamp Tracking
Modify Space interface to include:
interface Space {
id: string;
name: string;
// ... existing fields ...
// NEW: Conflict resolution metadata
version: number; // Incremented on each update
updatedAt: number; // Timestamp of last update
syncedAt?: number; // Timestamp of last sync with backend
}
🚨 Why Different Console Logs?
Each browser has:
- Different corrupted queue data (from before sync was enabled)
- Different local data (created while offline)
- Different sync timing (one might push before the other)
The corrupted queue causes:
⚠️ Unknown entity type: undefined - skipping sync
This is why clearing the queue is critical first step.
✅ Immediate Action Items
For Both Browsers:
Open DevTools Console (F12)
Run this command to clear corrupted queue:
// Clear sync queue await window.indexedDB.deleteDatabase('gograb-space-storage'); // Reload page location.reload();Verify clean state:
- Console should show no "undefined entity" errors
- Only one space should exist in backend (pick Browser A or B as source of truth)
Delete local storage and start fresh:
- Go to Application → Storage → Clear site data
- Reload page
- This will fetch fresh data from backend
🎯 Long-Term Strategy
For Production-Ready Offline-First:
- ✅ Implement
pullFromBackend()- Fetch backend state before pushing - ✅ Add conflict detection - Compare timestamps/versions
- ✅ Add smart merging - Union arrays, deep merge objects
- ✅ Add version tracking - Track changes with version numbers
- ⚠️ Consider CRDTs - For complex scenarios (Automerge, Yjs)
- ⚠️ Add conflict UI - Let users resolve conflicts manually when needed
Sync Flow Should Be:
1. Local change occurs → Queue operation
2. Background sync triggers:
a. PULL backend state
b. MERGE with local state
c. DETECT conflicts
d. RESOLVE conflicts (auto or prompt user)
e. PUSH merged state to backend
3. Update local storage with final state
📊 Current State
| Component | Status | Notes |
|---|---|---|
| Push to Backend | ✅ Working | Successfully pushes local changes |
| Pull from Backend | ❌ Not Implemented | Stub only, needs implementation |
| Conflict Detection | ❌ Missing | No version/timestamp comparison |
| Array Merging | ❌ Missing | Replaces instead of merging |
| Corrupted Queue | ⚠️ Needs Cleanup | Causing console errors |
🎓 Key Insights
- Seeing same space = Good! This means sync is partially working (space ID is synced)
- Different data = Expected! This is what happens when multiple clients work offline
- Different console logs = Corrupted queue Each browser has different bad data
- No auto-merge = Design gap The current implementation is "push-only"
📚 Related Documentation
🚀 Next Steps
- ✅ Clear corrupted data (both browsers)
- ✅ Implement
pullFromBackend()(with smart merging) - ✅ Add timestamp tracking to Space interface
- ✅ Test multi-browser sync with conflict scenarios
- ✅ Document conflict resolution for team
Last Updated: November 17, 2025
Status: Implementation In Progress