Mobile App Caching for Offline-First Design
Users expect apps to work on airplanes, in subways, and in areas with poor connectivity. Offline-first design treats network as an enhancement, not a requirement. Here's how to implement smart caching for mobile apps.
The Offline-First Mindset
Instead of "fetch then cache," think "cache then fetch":
- Show cached data immediately
- Fetch fresh data in background
- Update UI when new data arrives
- Queue mutations for sync when online
This delivers instant UI while keeping data fresh.
Local Storage Strategy
Choose the right storage for different data types:
// React Native storage options
// 1. AsyncStorage - Simple key-value (settings, tokens)
import AsyncStorage from '@react-native-async-storage/async-storage';
await AsyncStorage.setItem('user:preferences', JSON.stringify(prefs));
// 2. SQLite - Structured data (lists, complex queries)
import SQLite from 'react-native-sqlite-storage';
const db = await SQLite.openDatabase({ name: 'app.db' });
await db.executeSql('INSERT INTO posts VALUES (?, ?, ?)', [id, title, body]);
// 3. MMKV - High-performance key-value (frequent access)
import { MMKV } from 'react-native-mmkv';
const storage = new MMKV();
storage.set('cache:feed', JSON.stringify(feedData));
Performance tip: MMKV is 30x faster than AsyncStorage. Use it for data accessed frequently, like UI state and user preferences.
Cache-First Fetch Pattern
Return cached data immediately, update in background:
async function fetchWithCache(url, cacheKey, maxAge = 300000) {
// 1. Return cached data immediately if available
const cached = storage.getString(cacheKey);
const cachedMeta = storage.getString(`${cacheKey}:meta`);
let cachedData = null;
if (cached) {
cachedData = JSON.parse(cached);
const meta = cachedMeta ? JSON.parse(cachedMeta) : {};
// If fresh enough, just return cached
if (Date.now() - meta.fetchedAt < maxAge) {
return { data: cachedData, source: 'cache', stale: false };
}
}
// 2. Fetch fresh data (but don't block on it if we have cache)
try {
const response = await fetch(url);
const freshData = await response.json();
// Update cache
storage.set(cacheKey, JSON.stringify(freshData));
storage.set(`${cacheKey}:meta`, JSON.stringify({
fetchedAt: Date.now(),
etag: response.headers.get('etag')
}));
return { data: freshData, source: 'network', stale: false };
} catch (error) {
// Network failed - return stale cache if available
if (cachedData) {
return { data: cachedData, source: 'cache', stale: true };
}
throw error;
}
}
Offline Mutation Queue
Queue user actions when offline, sync when connected:
class OfflineMutationQueue {
constructor() {
this.queue = [];
this.loadQueue();
}
async loadQueue() {
const saved = storage.getString('mutation:queue');
if (saved) this.queue = JSON.parse(saved);
}
saveQueue() {
storage.set('mutation:queue', JSON.stringify(this.queue));
}
addMutation(type, payload) {
this.queue.push({
id: Date.now().toString(),
type,
payload,
createdAt: Date.now(),
retries: 0
});
this.saveQueue();
// Try to sync immediately if online
if (navigator.onLine) {
this.processQueue();
}
}
async processQueue() {
for (const mutation of [...this.queue]) {
try {
await this.executeMutation(mutation);
this.queue = this.queue.filter(m => m.id !== mutation.id);
this.saveQueue();
} catch (error) {
mutation.retries++;
if (mutation.retries > 5) {
this.queue = this.queue.filter(m => m.id !== mutation.id);
this.notifyFailure(mutation);
}
}
}
}
}
Conflict Resolution
When offline edits conflict with server changes:
async function syncWithConflictResolution(localData, serverData) {
// Last-write-wins (simple)
if (localData.updatedAt > serverData.updatedAt) {
await uploadToServer(localData);
return localData;
}
// Or merge changes (more complex)
if (hasConflict(localData, serverData)) {
const merged = mergeChanges(localData, serverData);
await uploadToServer(merged);
return merged;
}
// Server wins, update local
await saveToLocalCache(serverData);
return serverData;
}
function mergeChanges(local, server) {
// Field-level merge: keep most recent value for each field
const merged = { ...server };
for (const [key, value] of Object.entries(local.changes || {})) {
if (local.fieldTimestamps[key] > server.fieldTimestamps[key]) {
merged[key] = value;
}
}
return merged;
}
Background Sync
Sync data when app is in background:
// React Native Background Fetch
import BackgroundFetch from 'react-native-background-fetch';
BackgroundFetch.configure({
minimumFetchInterval: 15, // minutes
stopOnTerminate: false,
startOnBoot: true
}, async (taskId) => {
// Sync pending mutations
await mutationQueue.processQueue();
// Pre-fetch likely-needed data
await prefetchUserData();
BackgroundFetch.finish(taskId);
});
Cache Invalidation on Mobile
- Version-based: Invalidate cache when app updates
- Push notifications: Server tells client to refresh specific data
- Time-based: Respect max-age headers
- User-triggered: Pull-to-refresh clears relevant cache
Smart mobile caching SDK
Cachee.ai's mobile SDK handles offline sync, conflict resolution, and background refresh automatically.
Start Free Trial