← Back to Blog

Mobile App Caching for Offline-First Design

December 22, 2025 • 7 min read • Mobile Development

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":

  1. Show cached data immediately
  2. Fetch fresh data in background
  3. Update UI when new data arrives
  4. 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

Smart mobile caching SDK

Cachee.ai's mobile SDK handles offline sync, conflict resolution, and background refresh automatically.

Start Free Trial