Offline-first isn’t optional anymore — here’s how to do it properly with Appwrite.
Building mobile apps that work offline is a common challenge. Users expect apps to work without connectivity, sync when back online, and feel fast. This post covers an offline-first React Native app using Appwrite, TanStack Query, and modern patterns.
The Challenge: Why Offline-First Matters
Mobile networks are unreliable. Users face:
- Subway tunnels
- Airplane mode
- Weak signals
- Data limits
A traditional app that only works online leads to:
- Lost data
- Poor UX
- Frustration
An offline-first app:
- Works without connectivity
- Syncs automatically when online
- Feels instant with optimistic updates
- Preserves data across restarts
The Architecture: Building Blocks
Tech Stack
- React Native (Expo): Cross-platform framework
- Appwrite: Backend-as-a-Service with Realtime
- TanStack Query: Server state management with caching
- AsyncStorage: Local persistence (MMKV-ready)
- NetInfo: Network connectivity monitoring
- FlashList: High-performance list rendering
Core Architecture Decisions
1. Why AsyncStorage (with MMKV Path)?
For this PoC, I used AsyncStorage for Expo Go compatibility. The architecture is designed to migrate to MMKV for production.
MMKV advantages:
- 30-50x faster than AsyncStorage
- Synchronous API (no async overhead)
- Memory-mapped files (direct memory access)
- C++ implementation (native performance)
// AsyncStorage (current implementation)
const data = await AsyncStorage.getItem("key"); // ~10-50ms
// MMKV (production-ready alternative)
const data = storage.getString("key"); // ~0.1-1ms
The storage abstraction layer (lib/storage.ts) makes this migration straightforward—just swap the implementation.
2. Solving the "Double-Update" Problem
When a user creates a task, three updates can occur:
- Optimistic update (immediate UI)
- Mutation response (server confirms)
- Realtime event (broadcast to all clients)
Without proper handling, this causes duplicate updates, unnecessary refetches, and flickering.
Solution: Direct cache updates from Realtime
Instead of invalidating queries (which triggers refetches), we update the cache directly:
// ❌ Bad: Triggers refetch
queryClient.invalidateQueries({ queryKey });
// ✅ Good: Direct cache update
queryClient.setQueryData(queryKey, (oldData) => {
// Update cache directly from Realtime payload
return updatedData;
});
The useAppwriteSync hook handles this:
export function useAppwriteSync(queryKey: string[]) {
const queryClient = useQueryClient();
useEffect(() => {
const channel = `databases.${databaseId}.collections.${collectionId}.documents`;
const unsubscribe = realtime.subscribe<Task>(channel, (response) => {
if (response.events.includes('*.create')) {
const newTask = response.payload as Task;
// Direct cache update - no refetch needed!
queryClient.setQueryData<Task[]>(queryKey, (oldData) => {
// Smart deduplication logic here
return [...oldData, newTask];
});
}
// Handle update and delete similarly...
});
return () => unsubscribe(); // Critical: Cleanup on unmount
}, [queryClient, queryKey]);
}
Benefits:
- No duplicate updates
- Instant sync across devices
- Reduced network usage
- Better battery life
3. Optimistic UI Pattern
The Problem: Network latency makes apps feel slow.
The Solution: Update UI immediately, rollback on error.
export function useCreateTask() {
const queryClient = useQueryClient();
const { isConnected } = useConnection();
return useMutation({
mutationFn: createTask,
onMutate: async (newTask) => {
// 1. Cancel outgoing queries
await queryClient.cancelQueries({ queryKey });
// 2. Snapshot for rollback
const previous = queryClient.getQueryData(queryKey);
// 3. Create optimistic task
const optimisticTask = {
$id: `temp_${Date.now()}`,
...newTask,
pendingSync: !isConnected,
};
// 4. Optimistically update UI
queryClient.setQueryData(queryKey, [...old, optimisticTask]);
return { previous };
},
onError: (err, variables, context) => {
// Rollback on error (only if online)
if (isConnected && context?.previous) {
queryClient.setQueryData(queryKey, context.previous);
}
// If offline, keep the optimistic task - it's queued for sync!
},
onSuccess: (data) => {
// Replace optimistic task with real task from server
queryClient.setQueryData(queryKey, (old) => {
// Remove temp tasks and add real task
return old.filter(t => !t.$id.startsWith('temp_')).concat(data);
});
},
});
}
Benefits:
- 0ms perceived latency
- Works offline
- Automatic error handling
- Better UX
4. Offline Queue System
When offline, operations are queued and synced when connectivity returns.
// When creating a task offline
if (!isConnected) {
await addToOfflineQueue({
type: 'create',
payload: newTask,
});
}
// When connection is restored
useEffect(() => {
if (isConnected) {
syncQueue(); // Process all queued operations
}
}, [isConnected]);
The queue system (lib/offline-queue.ts) handles:
- Duplicate prevention
- Retry logic (max 3 retries)
- Operation ordering
- Persistence across app restarts
5. Conflict Resolution: Last Write Wins
When multiple devices update the same task, we resolve conflicts using Last Write Wins (LWW):
export function resolveConflict(localTask: Task, serverTask: Task): Task {
const localTime = new Date(localTask.$updatedAt).getTime();
const serverTime = new Date(serverTask.$updatedAt).getTime();
return serverTime >= localTime ? serverTask : localTask;
}
Why LWW?
- Simple and predictable
- Low overhead
- Works well for most use cases
Alternatives for future consideration:
- Operational Transform (OT): Better for concurrent edits
- CRDTs: Best for real-time collaboration
- Manual resolution: User decides
Key Implementation Patterns
Pattern 1: Cache Persistence
TanStack Query cache persists across app restarts using AsyncStorage:
const persister = createStoragePersister({
storage: {
getItem: async (key) => await AsyncStorage.getItem(key),
setItem: async (key, value) => await AsyncStorage.setItem(key, value),
removeItem: async (key) => await AsyncStorage.removeItem(key),
},
});
const queryClient = new QueryClient({
defaultOptions: {
queries: {
networkMode: 'offlineFirst', // Critical!
staleTime: 1000 * 60 * 5, // 5 minutes
},
},
});
Pattern 2: Connection Monitoring
The useConnection hook monitors network status:
export function useConnection() {
const [isConnected, setIsConnected] = useState<boolean | null>(null);
useEffect(() => {
const unsubscribe = NetInfo.addEventListener(state => {
setIsConnected(state.isConnected);
});
return () => unsubscribe();
}, []);
return { isConnected };
}
Pattern 3: Smart Deduplication
The Realtime sync hook includes deduplication:
// Check for existing task by ID
const existsById = oldData.some(task => task.$id === newTask.$id);
// Check for optimistic task match (by content + timestamp)
const optimisticMatch = oldData.findIndex(task => {
return (
task.$id.startsWith('temp_') &&
task.title === newTask.title &&
Math.abs(task.localTimestamp - newTask.$createdAt) < 60000
);
});
// Replace optimistic task with real task
if (optimisticMatch >= 0) {
updated[optimisticMatch] = newTask;
return updated;
}
Performance Optimizations
1. FlashList for Rendering
Using FlashList instead of FlatList:
- 30-50% faster rendering
- Lower memory usage
- Smoother scrolling
2. Network Mode: offlineFirst
networkMode: 'offlineFirst'
This allows queries to run even when offline, using cached data.
3. Battery Efficiency
- Realtime subscription cleanup on unmount
- Debounced cache persistence (1 second)
- Exponential backoff retry logic
Testing
To demonstrate offline-first:
1.Start with the app online: Show normal operation
2.Turn on Airplane Mode: Create tasks while offline
3.Show pending sync indicators: Highlight the ⏳ icon
4.Turn off Airplane Mode: Watch tasks sync automatically
5.Open Appwrite Console: Show data appearing in real-time
This demonstrates the power of offline-first architecture.
Lessons Learned
1. Storage Choice Matters
AsyncStorage works for Expo Go, but MMKV is 30-50x faster. For production, migrate to MMKV for better performance.
2. Direct Cache Updates > Refetches
Updating the cache directly from Realtime events eliminates duplicate updates and reduces network usage.
3. Optimistic Updates Are Essential
Users expect instant feedback. Optimistic updates make the app feel fast even on slow networks.
4. Offline Queue Needs Deduplication
Without deduplication, the same operation can sync multiple times. Smart matching prevents duplicates.
5. Cleanup Is Critical
Always unsubscribe from Realtime subscriptions on unmount to prevent battery drain and memory leaks.
Conclusion
Building offline-first apps requires:
- Smart caching (TanStack Query + persistence)
- Optimistic updates (instant feedback)
- Offline queue (sync when online)
- Direct cache updates (avoid refetches)
- Conflict resolution (handle edge cases)
This architecture provides:
- Seamless offline experience
- Automatic sync when online
- Fast, responsive UI
- Data persistence across restarts
- Real-time multi-device sync
The codebase is available as a reference implementation. Feel free to use it as a starting point for your own offline-first apps.
Built with ❤️ for the Appwrite community







Top comments (0)