DEV Community

Cover image for Building an Offline-First React Native App with Appwrite: A Practical Architecture Guide

Building an Offline-First React Native App with Appwrite: A Practical Architecture Guide

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. Optimistic update (immediate UI)
  2. Mutation response (server confirms)
  3. 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;
});
Enter fullscreen mode Exit fullscreen mode

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]);
}
Enter fullscreen mode Exit fullscreen mode

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);
      });
    },
  });
}
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

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 };
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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

Image of app in normal operation

Image of app when online task is added

Image of databse when task is created when online

2.Turn on Airplane Mode: Create tasks while offline

Image of app when offline

3.Show pending sync indicators: Highlight the ⏳ icon

Image of pending sync

4.Turn off Airplane Mode: Watch tasks sync automatically

Image after sync is completed

5.Open Appwrite Console: Show data appearing in real-time

Image of data 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)