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));
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 TrialRelated Reading
Real-World Implementation Notes
Production cache deployments don't fail because the technology is wrong. They fail because of three operational problems that nobody warns you about until you're already in the incident.
The first problem is configuration drift. Cache TTLs, eviction policies, and memory limits start out tuned to your workload and slowly drift as your traffic patterns evolve. A configuration that was optimal six months ago is now leaving 30% of your hit rate on the table because your access patterns shifted and nobody re-tuned. The fix is treating cache configuration as code that lives in version control with the rest of your infrastructure, and reviewing it on the same cadence as database indexes — quarterly at minimum.
The second problem is silent invalidation bugs. Your cache returns a value, your application uses it, and only later does someone notice the value was stale. The user already saw the wrong number on their dashboard. The damage is done. The mitigation is instrumenting your cache layer to track stale-read rates and treating any spike above 0.5% as a P1 incident, not a "we'll look at it next sprint" backlog item.
The third problem is eviction storms during deploys. When you deploy a new version of your application that changes which keys are hot, the existing cache entries become irrelevant overnight. The first few minutes after deploy see a flood of cache misses that hammer your backend. The mitigation is cache warming — running your application against a representative traffic sample before promoting it to serve production traffic. Most teams skip this step and pay for it every release.
None of these problems are technology problems. They're operational discipline problems that the right tools make visible but only humans can actually solve. The cache layer is part of your production system and deserves the same operational attention as any other production component.
The Numbers That Matter
Cache performance discussions get philosophical fast. Here are the actual measured numbers from production deployments running on documented hardware, so you can compare against your own infrastructure instead of trusting marketing copy.
- L0 hot path GET: 28.9 nanoseconds on Apple M4 Max, single-threaded against pre-warmed in-memory cache. This is the floor — there's no faster way to read a key.
- L1 CacheeLFU GET: ~89 nanoseconds on AWS Graviton4 (c8g.metal-48xl). Sharded DashMap with admission filtering.
- Sustained throughput: 32 million ops/sec single-threaded on M4 Max, 7.41 million ops/sec at 16 workers on Graviton4 c8g.16xlarge.
- L2 fallback: Sub-millisecond hits against ElastiCache Redis 7.4 over same-AZ network when L1 misses cascade through.
The compounding effect matters more than any single number. A 28-nanosecond L0 hit means your application spends almost zero time on cache lookups in the hot path, leaving the CPU free for the actual business logic that generates revenue.
The Three-Tier Cache Architecture That Actually Works
Most caching discussions treat the cache as a single layer. Production reality is that high-performance caches are tiered, with each tier optimized for a different latency and capacity tradeoff. Understanding the tier boundaries is what separates teams that get caching right from teams that fight it for years.
L0 — In-process hot tier. This is the cache that lives inside your application process address space. Read latency is bounded by L1/L2 CPU cache plus a hash function — typically 20-100 nanoseconds. Capacity is limited by your application's heap budget, usually 1-10 GB on production servers. Hit rate on hot keys approaches 100% because there's no network in the path. This is where your tightest hot loop reads should land.
L1 — Local sidecar tier. A cache process running on the same host (or in the same pod for Kubernetes deployments) accessed via Unix domain socket or loopback TCP. Read latency is 5-50 microseconds depending on protocol overhead. Capacity is bounded by host RAM, typically 10-100 GB. This tier absorbs cross-process cache traffic from multiple application instances on the same host without paying the network round-trip cost.
L2 — Distributed remote tier. Networked Redis, ElastiCache, or Memcached. Read latency is 100 microseconds to several milliseconds depending on network distance. Capacity is effectively unbounded by clustering. This is the source of truth for cached values across your entire fleet, and the L0/L1 tiers fall back to it on miss.
The compounding effect is what makes this architecture win. When the L0 hit rate is 90%, the L1 hit rate is 95% on the remaining 10%, and the L2 hit rate is 99% on the remainder, your effective cache hit rate is 99.95% with the median read served entirely from L0 in tens of nanoseconds. That's a different universe of performance than treating the cache as a single networked tier.
What This Actually Costs
Concrete pricing math beats hypothetical. A typical SaaS workload with 1 billion cache operations per month, average 800-byte values, and a 5 GB hot working set currently runs on AWS ElastiCache cache.r7g.xlarge primary plus a read replica — roughly $480 per month for the two nodes, plus cross-AZ data transfer charges that quietly add another $50-150 per month depending on access patterns.
Migrating the hot path to an in-process L0/L1 cache and keeping ElastiCache as a cold L2 fallback drops the dedicated cache spend to $120-180 per month. For workloads where the hot working set fits inside the application's existing memory budget, you can eliminate the dedicated cache tier entirely. The cache becomes a library you link into your binary instead of a separate service to operate.
Compounded over twelve months, that's $3,600 to $4,500 per year on a single small workload. Multiply across a fleet of services and the savings start showing up in finance team conversations. The bigger savings usually come from eliminating cross-AZ data transfer charges, which Redis-as-a-service architectures incur on every read that crosses an availability zone.