home / skills / aj-geddes / useful-ai-prompts / mobile-offline-support

mobile-offline-support skill

/skills/mobile-offline-support

This skill helps you design and implement offline-first mobile apps with local storage, sync strategies, and conflict resolution.

npx playbooks add skill aj-geddes/useful-ai-prompts --skill mobile-offline-support

Review the files below or copy the command above to add this skill to your agents.

Files (1)
SKILL.md
11.9 KB
---
name: mobile-offline-support
description: Implement offline-first mobile apps with local storage, sync strategies, and conflict resolution. Covers AsyncStorage, Realm, SQLite, and background sync patterns.
---

# Mobile Offline Support

## Overview

Design offline-first mobile applications that provide seamless user experience regardless of connectivity.

## When to Use

- Building apps that work without internet connection
- Implementing seamless sync when connectivity returns
- Handling data conflicts between device and server
- Reducing server load with intelligent caching
- Improving app responsiveness with local storage

## Instructions

### 1. **React Native Offline Storage**

```javascript
import AsyncStorage from '@react-native-async-storage/async-storage';
import NetInfo from '@react-native-community/netinfo';

class StorageManager {
  static async saveItems(items) {
    try {
      await AsyncStorage.setItem(
        'items_cache',
        JSON.stringify({ data: items, timestamp: Date.now() })
      );
    } catch (error) {
      console.error('Failed to save items:', error);
    }
  }

  static async getItems() {
    try {
      const data = await AsyncStorage.getItem('items_cache');
      return data ? JSON.parse(data) : null;
    } catch (error) {
      console.error('Failed to retrieve items:', error);
      return null;
    }
  }

  static async queueAction(action) {
    try {
      const queue = await AsyncStorage.getItem('action_queue');
      const actions = queue ? JSON.parse(queue) : [];
      actions.push({ ...action, id: Date.now(), attempts: 0 });
      await AsyncStorage.setItem('action_queue', JSON.stringify(actions));
    } catch (error) {
      console.error('Failed to queue action:', error);
    }
  }

  static async getActionQueue() {
    try {
      const queue = await AsyncStorage.getItem('action_queue');
      return queue ? JSON.parse(queue) : [];
    } catch (error) {
      return [];
    }
  }

  static async removeFromQueue(actionId) {
    try {
      const queue = await AsyncStorage.getItem('action_queue');
      const actions = queue ? JSON.parse(queue) : [];
      const filtered = actions.filter(a => a.id !== actionId);
      await AsyncStorage.setItem('action_queue', JSON.stringify(filtered));
    } catch (error) {
      console.error('Failed to remove from queue:', error);
    }
  }
}

class OfflineAPIService {
  async fetchItems() {
    const isOnline = await this.checkConnectivity();

    if (isOnline) {
      try {
        const response = await fetch('https://api.example.com/items');
        const items = await response.json();
        await StorageManager.saveItems(items);
        return items;
      } catch (error) {
        const cached = await StorageManager.getItems();
        return cached?.data || [];
      }
    } else {
      const cached = await StorageManager.getItems();
      return cached?.data || [];
    }
  }

  async createItem(item) {
    const isOnline = await this.checkConnectivity();

    if (isOnline) {
      try {
        const response = await fetch('https://api.example.com/items', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(item)
        });
        const created = await response.json();
        return { success: true, data: created };
      } catch (error) {
        await StorageManager.queueAction({
          type: 'CREATE_ITEM',
          payload: item
        });
        return { success: false, queued: true };
      }
    } else {
      await StorageManager.queueAction({
        type: 'CREATE_ITEM',
        payload: item
      });
      return { success: false, queued: true };
    }
  }

  async syncQueue() {
    const queue = await StorageManager.getActionQueue();

    for (const action of queue) {
      try {
        await this.executeAction(action);
        await StorageManager.removeFromQueue(action.id);
      } catch (error) {
        action.attempts = (action.attempts || 0) + 1;
        if (action.attempts > 3) {
          await StorageManager.removeFromQueue(action.id);
        }
      }
    }
  }

  private async executeAction(action) {
    switch (action.type) {
      case 'CREATE_ITEM':
        return fetch('https://api.example.com/items', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(action.payload)
        });
      default:
        return Promise.reject(new Error('Unknown action type'));
    }
  }

  async checkConnectivity() {
    const state = await NetInfo.fetch();
    return state.isConnected ?? false;
  }
}

export function OfflineListScreen() {
  const [items, setItems] = useState([]);
  const [isOnline, setIsOnline] = useState(true);
  const [syncing, setSyncing] = useState(false);
  const apiService = new OfflineAPIService();

  useFocusEffect(
    useCallback(() => {
      loadItems();
      const unsubscribe = NetInfo.addEventListener(state => {
        setIsOnline(state.isConnected ?? false);
        if (state.isConnected) {
          syncQueue();
        }
      });

      return unsubscribe;
    }, [])
  );

  const loadItems = async () => {
    const items = await apiService.fetchItems();
    setItems(items);
  };

  const syncQueue = async () => {
    setSyncing(true);
    await apiService.syncQueue();
    await loadItems();
    setSyncing(false);
  };

  return (
    <View style={styles.container}>
      {!isOnline && <Text style={styles.offline}>Offline Mode</Text>}
      {syncing && <ActivityIndicator size="large" />}
      <FlatList
        data={items}
        renderItem={({ item }) => <ItemCard item={item} />}
        keyExtractor={item => item.id}
      />
    </View>
  );
}
```

### 2. **iOS Core Data Implementation**

```swift
import CoreData

class PersistenceController {
  static let shared = PersistenceController()

  let container: NSPersistentContainer

  init(inMemory: Bool = false) {
    container = NSPersistentContainer(name: "MyApp")

    if inMemory {
      container.persistentStoreDescriptions.first?.url = URL(fileURLWithPath: "/dev/null")
    }

    container.loadPersistentStores { _, error in
      if let error = error as NSError? {
        print("Core Data load error: \(error)")
      }
    }

    container.viewContext.automaticallyMergesChangesFromParent = true
  }

  func save(_ context: NSManagedObjectContext = PersistenceController.shared.container.viewContext) {
    if context.hasChanges {
      do {
        try context.save()
      } catch {
        print("Save error: \(error)")
      }
    }
  }
}

// Core Data Models
@NSManaged class ItemEntity: NSManagedObject {
  @NSManaged var id: String
  @NSManaged var title: String
  @NSManaged var description: String?
  @NSManaged var isSynced: Bool
}

@NSManaged class ActionQueueEntity: NSManagedObject {
  @NSManaged var id: UUID
  @NSManaged var type: String
  @NSManaged var payload: Data?
  @NSManaged var createdAt: Date
}

class OfflineSyncManager: NSObject, ObservableObject {
  @Published var isOnline = true
  @Published var isSyncing = false

  private let networkMonitor = NWPathMonitor()
  private let persistenceController = PersistenceController.shared

  override init() {
    super.init()
    setupNetworkMonitoring()
  }

  private func setupNetworkMonitoring() {
    networkMonitor.pathUpdateHandler = { [weak self] path in
      DispatchQueue.main.async {
        self?.isOnline = path.status == .satisfied
        if path.status == .satisfied {
          self?.syncWithServer()
        }
      }
    }

    let queue = DispatchQueue(label: "NetworkMonitor")
    networkMonitor.start(queue: queue)
  }

  func saveItem(_ item: Item) {
    let context = persistenceController.container.viewContext
    let entity = ItemEntity(context: context)
    entity.id = item.id
    entity.title = item.title
    entity.isSynced = false

    persistenceController.save(context)

    if isOnline {
      syncItem(item)
    }
  }

  func syncWithServer() {
    isSyncing = true
    let context = persistenceController.container.viewContext
    let request: NSFetchRequest<ActionQueueEntity> = ActionQueueEntity.fetchRequest()

    do {
      let pendingActions = try context.fetch(request)
      for action in pendingActions {
        context.delete(action)
      }
      persistenceController.save(context)
    } catch {
      print("Sync error: \(error)")
    }

    isSyncing = false
  }
}
```

### 3. **Android Room Database**

```kotlin
@Entity(tableName = "items")
data class ItemEntity(
  @PrimaryKey val id: String,
  val title: String,
  val description: String?,
  val isSynced: Boolean = false
)

@Entity(tableName = "action_queue")
data class ActionQueueEntity(
  @PrimaryKey val id: Long = System.currentTimeMillis(),
  val type: String,
  val payload: String,
  val createdAt: Long = System.currentTimeMillis()
)

@Dao
interface ItemDao {
  @Insert(onConflict = OnConflictStrategy.REPLACE)
  suspend fun insertItem(item: ItemEntity)

  @Query("SELECT * FROM items")
  fun getAllItems(): Flow<List<ItemEntity>>

  @Update
  suspend fun updateItem(item: ItemEntity)
}

@Dao
interface ActionQueueDao {
  @Insert
  suspend fun insertAction(action: ActionQueueEntity)

  @Query("SELECT * FROM action_queue ORDER BY createdAt ASC")
  suspend fun getAllActions(): List<ActionQueueEntity>

  @Delete
  suspend fun deleteAction(action: ActionQueueEntity)
}

@Database(entities = [ItemEntity::class, ActionQueueEntity::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
  abstract fun itemDao(): ItemDao
  abstract fun actionQueueDao(): ActionQueueDao
}

@HiltViewModel
class OfflineItemsViewModel @Inject constructor(
  private val itemDao: ItemDao,
  private val actionQueueDao: ActionQueueDao,
  private val connectivityManager: ConnectivityManager
) : ViewModel() {
  private val _items = MutableStateFlow<List<Item>>(emptyList())
  val items: StateFlow<List<Item>> = _items.asStateFlow()

  init {
    viewModelScope.launch {
      itemDao.getAllItems().collect { entities ->
        _items.value = entities.map { it.toItem() }
      }
    }
    observeNetworkConnectivity()
  }

  fun saveItem(item: Item) {
    viewModelScope.launch {
      val entity = item.toEntity()
      itemDao.insertItem(entity)

      if (isNetworkAvailable()) {
        syncItem(item)
      } else {
        actionQueueDao.insertAction(
          ActionQueueEntity(
            type = "CREATE_ITEM",
            payload = Json.encodeToString(item)
          )
        )
      }
    }
  }

  private fun observeNetworkConnectivity() {
    val networkRequest = NetworkRequest.Builder()
      .addCapability(NET_CAPABILITY_INTERNET)
      .build()

    connectivityManager.registerNetworkCallback(
      networkRequest,
      object : ConnectivityManager.NetworkCallback() {
        override fun onAvailable(network: Network) {
          viewModelScope.launch { syncQueue() }
        }
      }
    )
  }

  private suspend fun syncQueue() {
    val queue = actionQueueDao.getAllActions()
    for (action in queue) {
      try {
        actionQueueDao.deleteAction(action)
      } catch (e: Exception) {
        println("Sync error: ${e.message}")
      }
    }
  }

  private fun isNetworkAvailable(): Boolean {
    val activeNetwork = connectivityManager.activeNetwork ?: return false
    val capabilities = connectivityManager.getNetworkCapabilities(activeNetwork) ?: return false
    return capabilities.hasCapability(NET_CAPABILITY_INTERNET)
  }
}
```

## Best Practices

### ✅ DO
- Implement robust local storage
- Use automatic sync when online
- Provide visual feedback for offline status
- Queue actions for later sync
- Handle conflicts gracefully
- Cache frequently accessed data
- Implement proper error recovery
- Test offline scenarios thoroughly
- Use compression for large data
- Monitor storage usage

### ❌ DON'T
- Assume constant connectivity
- Sync large files frequently
- Ignore storage limitations
- Force unnecessary syncing
- Lose data on offline mode
- Store sensitive data unencrypted
- Accumulate infinite queue items
- Ignore sync failures silently
- Sync in tight loops
- Deploy without offline testing

Overview

This skill shows how to implement offline-first mobile apps with local storage, background sync strategies, and conflict resolution patterns. It covers practical implementations using AsyncStorage, Realm/SQLite patterns, iOS Core Data, and Android Room, plus queueing and network monitoring to ensure reliable sync. The goal is resilient apps that work seamlessly when connectivity fluctuates.

How this skill works

The skill provides code patterns to cache data locally, queue user actions while offline, and replay those actions when connectivity returns. It demonstrates network monitoring, retry and backoff for queued actions, and merge strategies to resolve conflicts between local and server state. Examples include persistent stores (Core Data, Room), simple AsyncStorage queues, and syncing logic executed on network availability.

When to use it

  • Building apps that must work reliably without internet access
  • Queuing user operations locally and syncing later
  • Reducing server load through caching and local reads
  • Handling conflicting edits between device and server
  • Improving perceived performance by reading from local storage

Best practices

  • Persist user actions in a queue with retry limits and timestamps
  • Provide clear UI feedback for offline status and sync progress
  • Merge conflicts with deterministic rules (timestamps, priorities, or CRDTs)
  • Limit queue size and purge or escalate long-failed actions
  • Encrypt sensitive data at rest and monitor storage usage
  • Test offline flows and network transitions on real devices

Example use cases

  • A note-taking app that saves drafts locally and syncs when online
  • E-commerce cart and checkout actions queued for later processing
  • Field data collection where connectivity is intermittent
  • Chat or messaging apps that persist messages offline then sync
  • Content-heavy apps that cache articles and media for offline reading

FAQ

How do I avoid duplicate actions when syncing?

Assign stable client-generated IDs and deduplicate on the server; remove queued items only after successful confirmation.

What should I store locally vs on the server?

Store frequently read and required offline data locally; keep large or sensitive files on the server and sync metadata or compressed variants when needed.