
Offline-First Mobile Apps: How to Design for Unreliable Networks

In today's hyperconnected world, it's easy to forget that reliable network connectivity remains an elusive luxury for many mobile users. From rural areas with spotty coverage to underground transportation, international travel, and unexpected outages, scenarios where connectivity falters are common. Yet many mobile applications are still designed with an implicit assumption of constant connectivity, leading to frustrated users and abandoned sessions when networks inevitably fail. This article explores the offline-first approach to mobile development—a philosophy and set of techniques that prioritize a seamless experience regardless of connectivity status.
The Offline-First Philosophy
Offline-first isn't merely a technical approach but a fundamental shift in how we conceptualize mobile applications. Rather than treating offline functionality as an exception or fallback, it positions offline capability as the default state, with connectivity enhancing the experience when available.
Core Principles
- Local-first data management: Store essential data locally and treat the server as a synchronization point rather than the primary data source.
- Progressive enhancement: Core functionality works offline, with additional features enabled when connectivity is available.
- Transparent synchronization: Data transfers occur in the background without disrupting the user experience.
- Optimistic UI updates: Interface reflects user actions immediately, even before server confirmation.
- Graceful degradation: When certain features cannot function offline, provide clear alternative paths rather than disruptive error messages.
Business Benefits Beyond Reliability
Beyond the obvious advantages of reliability, offline-first applications provide substantial business benefits:
- Improved user retention: Applications that work seamlessly offline show 30-40% higher retention rates according to recent studies.
- Reduced server load: Offline-first architectures typically reduce server requests by 40-60% during normal usage.
- Higher engagement: Users engage more frequently with applications they trust to work consistently.
- Lower bandwidth costs: Strategic synchronization reduces data transfer volumes for both users and providers.
Architectural Foundations for Offline Capability
Building a truly offline-capable application requires thoughtful architecture from the ground up, with several key components working in harmony.
Local Data Storage Strategies
The foundation of any offline-first application is robust local storage. Modern mobile platforms offer several options, each with distinct advantages:
Platform-Native Storage Solutions
iOS Core Data and Android Room provide structured storage with relational capabilities:
// iOS Core Data example
let task = Task(context: viewContext)
task.id = UUID()
task.title = "Complete offline sync module"
task.createdAt = Date()
task.isCompleted = false
task.syncStatus = "pending"
do {
try viewContext.save()
} catch {
print("Error saving task: \(error)")
}
// Android Room example
@Entity(tableName = "tasks")
data class Task(
@PrimaryKey val id: String,
val title: String,
val createdAt: Long,
val isCompleted: Boolean,
val syncStatus: String
)
// In repository class
suspend fun saveTask(task: Task) {
taskDao.insert(task)
}
Cross-Platform Storage Solutions
For frameworks like React Native, Flutter, or Xamarin, cross-platform storage libraries provide consistency:
// React Native with WatermelonDB
import { Database } from "@nozbe/watermelondb";
import { synchronize } from "@nozbe/watermelondb/sync";
// In component
const saveTask = async (title) => {
await database.write(async () => {
await tasksCollection.create((task) => {
task.title = title;
task.created_at = new Date();
task.is_completed = false;
task.sync_status = "pending";
});
});
};
// Flutter with Drift (formerly Moor)
@DataClassName('Task')
class Tasks extends Table {
TextColumn get id => text()();
TextColumn get title => text()();
DateTimeColumn get createdAt => dateTime()();
BoolColumn get isCompleted => boolean().withDefault(const Constant(false))();
TextColumn get syncStatus => text().withDefault(const Constant('pending'))();
@override
Set<Column> get primaryKey => {id};
}
// In repository
Future<void> saveTask(Task task) async {
await (into(tasks).insert(task));
}
Modern Approaches: Local-First Databases
A new generation of databases is emerging specifically designed for offline-first applications:
// Using RxDB with React Native
import { createRxDatabase } from "rxdb";
const database = await createRxDatabase({
name: "taskdb",
adapter: getRNDBAdapter(),
});
await database.addCollections({
tasks: {
schema: {
title: "tasks schema",
version: 0,
primaryKey: "id",
type: "object",
properties: {
id: { type: "string" },
title: { type: "string" },
created_at: { type: "number" },
is_completed: { type: "boolean" },
sync_status: { type: "string" },
},
},
},
});
// Subscribe to changes (reactive)
const subscription = database.tasks.find().$.subscribe((tasks) => {
// Update UI with tasks
});
These local-first databases typically offer built-in synchronization capabilities, simplifying the offline-first implementation.
Synchronization Strategies
With local storage in place, the next critical component is an effective synchronization strategy that efficiently transfers data when connectivity becomes available.
Change-Based Synchronization
This approach tracks changes made while offline and applies them sequentially when online:
// Conceptual implementation with a change log
class ChangeTracker {
logChange(entity, type, data) {
return db.changeLog.insert({
entityType: entity,
changeType: type, // 'create', 'update', 'delete'
entityId: data.id,
payload: JSON.stringify(data),
timestamp: Date.now(),
applied: false,
});
}
async synchronize() {
const pendingChanges = await db.changeLog
.find({ applied: false })
.sort({ timestamp: 1 })
.toArray();
for (const change of pendingChanges) {
try {
// Apply change to server
await apiClient.applyChange(change);
// Mark as applied
await db.changeLog.update(
{ _id: change._id },
{ $set: { applied: true } }
);
} catch (error) {
// Handle sync error
if (isNetworkError(error)) {
// Retry later
break;
} else if (isConflictError(error)) {
// Handle conflict
await conflictResolver.resolve(change, error.serverData);
}
}
}
}
}
Incremental State Synchronization
Rather than tracking individual changes, this method compares local state with server state and synchronizes differences:
// Using a timestamp-based approach
async function syncTasks() {
// Get last sync timestamp
const lastSync = (await storage.getItem("lastTasksSync")) || 0;
// Fetch server changes since last sync
const serverChanges = await api.getTasks({ updatedSince: lastSync });
// Get local changes
const localChanges = await db.tasks
.find({ updatedAt: { $gt: lastSync } })
.toArray();
// Apply server changes to local DB (careful with conflict handling)
for (const task of serverChanges) {
const localTask = localChanges.find((t) => t.id === task.id);
if (localTask) {
// Potential conflict - use strategy (server wins, client wins, merge)
await resolveConflict(localTask, task);
} else {
await db.tasks.upsert(task);
}
}
// Push local changes to server
for (const task of localChanges) {
// Check if already handled in server changes
if (!serverChanges.some((t) => t.id === task.id)) {
await api.updateTask(task);
}
}
// Update sync timestamp
await storage.setItem("lastTasksSync", Date.now());
}
Operational Transformation
For collaborative applications, operational transformation (OT) or Conflict-free Replicated Data Types (CRDTs) provide sophisticated conflict resolution:
// Using Yjs for collaborative editing
import * as Y from "yjs";
import { WebsocketProvider } from "y-websocket";
// Create a shared document
const ydoc = new Y.Doc();
// Define a shared text type
const ytext = ydoc.getText("shared-document");
// Connect to a websocket provider when online
const wsProvider = new WebsocketProvider(
"wss://sync-server.example.com",
"document-id",
ydoc
);
// Handle online state
wsProvider.on("status", (event) => {
if (event.status === "connected") {
// Online: Updates will sync automatically
console.log("Connected to sync server");
} else {
// Offline: Changes stored locally and synced later
console.log("Disconnected from sync server");
}
});
// Make changes locally (works offline)
ytext.insert(0, "Hello offline world");
// Changes automatically merge when connection is restored
Hybrid API Architectures
Traditional REST APIs often struggle with offline scenarios. Modern offline-first applications benefit from hybrid API architectures that accommodate both online and offline states.
Queue-Based Request Architecture
Outgoing API requests are queued locally and processed when connectivity is available:
// Request queue implementation
class RequestQueue {
constructor(apiClient) {
this.queue = [];
this.apiClient = apiClient;
this.processing = false;
// Monitor connectivity
this.connectivityMonitor.on("online", () => this.processQueue());
}
async enqueue(request) {
// Add request to queue
this.queue.push({
id: uuidv4(),
endpoint: request.endpoint,
method: request.method,
data: request.data,
timestamp: Date.now(),
});
// Save queue to persistent storage
await this.persistQueue();
// Process immediately if online
if (await this.connectivityMonitor.isOnline()) {
this.processQueue();
}
return true; // Optimistic response
}
async processQueue() {
if (this.processing || this.queue.length === 0) return;
this.processing = true;
while (
this.queue.length > 0 &&
(await this.connectivityMonitor.isOnline())
) {
const request = this.queue[0];
try {
// Attempt API call
await this.apiClient.request({
url: request.endpoint,
method: request.method,
data: request.data,
});
// Remove from queue on success
this.queue.shift();
await this.persistQueue();
// Notify listeners of successful processing
this.emit("requestProcessed", request);
} catch (error) {
// If server error, remove and notify
if (error.status >= 400 && error.status < 500) {
this.queue.shift();
await this.persistQueue();
this.emit("requestFailed", request, error);
} else {
// Network error, try again later
this.processing = false;
return;
}
}
}
this.processing = false;
}
async persistQueue() {
await localStorage.setItem("requestQueue", JSON.stringify(this.queue));
}
async loadQueue() {
const saved = await localStorage.getItem("requestQueue");
if (saved) this.queue = JSON.parse(saved);
}
}
GraphQL with Offline Support
GraphQL provides flexible data querying that works well with offline scenarios:
// Using Apollo Client with offline support
import { ApolloClient, InMemoryCache } from "@apollo/client";
import { persistCache } from "apollo3-cache-persist";
import { QueueLink } from "apollo-link-queue";
import { HttpLink } from "@apollo/client/link/http";
import { RetryLink } from "@apollo/client/link/retry";
async function setupApolloClient() {
// Create cache and persist it
const cache = new InMemoryCache();
await persistCache({
cache,
storage: window.localStorage,
});
// Queue link for offline support
const queueLink = new QueueLink();
// Track online status
window.addEventListener("online", () => queueLink.open());
window.addEventListener("offline", () => queueLink.close());
// Create primary link with retry capability
const httpLink = new HttpLink({ uri: "https://api.example.com/graphql" });
const retryLink = new RetryLink();
// Combine links
const link = retryLink.concat(queueLink.concat(httpLink));
// Create Apollo Client
const client = new ApolloClient({
link,
cache,
defaultOptions: {
watchQuery: {
fetchPolicy: "cache-and-network",
},
},
});
return client;
}
User Experience Design for Offline States
A truly offline-first application addresses not just technical challenges but also user experience considerations to maintain user confidence during connectivity transitions.
Transparent Connectivity Status
Modern offline applications provide subtle but clear indicators of connectivity:
// React Native connectivity indicator
function ConnectivityBar() {
const [isConnected, setIsConnected] = useState(true);
useEffect(() => {
const unsubscribe = NetInfo.addEventListener((state) => {
setIsConnected(state.isConnected);
});
return () => unsubscribe();
}, []);
if (isConnected) return null;
return (
<Animated.View style={styles.offlineBar}>
<Text style={styles.offlineText}>
You're offline. Changes will sync when connection is restored.
</Text>
</Animated.View>
);
}
Optimistic UI Updates
Don't make users wait for server responses—update the UI immediately and reconcile later:
// Optimistic update pattern with error handling
function createTaskOptimistic(taskData) {
// Generate a temporary ID
const tempId = `temp-${Date.now()}`;
// Create optimistic task record
const optimisticTask = {
id: tempId,
...taskData,
createdAt: new Date(),
isCompleted: false,
// Flag as optimistic/pending
_status: "pending",
};
// Add to local state immediately
dispatch({ type: "ADD_TASK", task: optimisticTask });
// Attempt to save to server
api
.createTask(taskData)
.then((response) => {
// Replace temporary record with server response
dispatch({
type: "UPDATE_TASK",
tempId,
task: {
...response.data,
_status: "synced",
},
});
})
.catch((error) => {
if (isNetworkError(error)) {
// Mark for retry when online
dispatch({
type: "UPDATE_TASK",
tempId,
task: {
...optimisticTask,
_status: "offline",
},
});
} else {
// Server rejected task
dispatch({
type: "UPDATE_TASK",
tempId,
task: {
...optimisticTask,
_status: "error",
_error: error.message,
},
});
}
});
}
Synchronization Progress Indicators
For large data transfers, provide clear progress indicators:
// React component for sync progress
function SyncProgressIndicator() {
const [syncState, setSyncState] = useState({
isSyncing: false,
progress: 0,
total: 0,
});
useEffect(() => {
const subscription = syncManager.events.subscribe((event) => {
if (event.type === "sync_started") {
setSyncState({
isSyncing: true,
progress: 0,
total: event.total,
});
} else if (event.type === "sync_progress") {
setSyncState((state) => ({
...state,
progress: event.progress,
}));
} else if (event.type === "sync_completed") {
setSyncState({
isSyncing: false,
progress: 0,
total: 0,
});
}
});
return () => subscription.unsubscribe();
}, []);
if (!syncState.isSyncing) return null;
return (
<View style={styles.syncContainer}>
<Progress.Bar
progress={syncState.progress / syncState.total}
width={200}
/>
<Text style={styles.syncText}>
Syncing {syncState.progress} of {syncState.total} items...
</Text>
</View>
);
}
Graceful Feature Degradation
Some features inherently require connectivity. Rather than disabling them entirely, implement graceful degradation:
// Feature with graceful degradation
function PhotoUploader({ onUpload }) {
const { isConnected } = useConnectivity();
const [queued, setQueued] = useState([]);
const handleSelectImage = async () => {
const image = await ImagePicker.launchImageLibraryAsync();
if (image.cancelled) return;
if (isConnected) {
// When online, upload immediately
const result = await uploadImage(image.uri);
onUpload(result.url);
} else {
// When offline, queue for later
const queueId = await imageQueue.add(image.uri);
setQueued((prev) => [...prev, { id: queueId, uri: image.uri }]);
// Show preview with pending status
onUpload(image.uri, { isPending: true, queueId });
}
};
return (
<View style={styles.container}>
<TouchableOpacity onPress={handleSelectImage}>
<View style={styles.uploadButton}>
<Icon name="camera" size={24} />
<Text style={styles.buttonText}>
{isConnected ? "Upload Photo" : "Select Photo for Later Upload"}
</Text>
</View>
</TouchableOpacity>
{queued.length > 0 && (
<View style={styles.queuedContainer}>
<Text style={styles.queuedTitle}>
{queued.length} photos queued for upload
</Text>
<Text style={styles.queuedSubtitle}>
Will upload automatically when connection is restored
</Text>
</View>
)}
</View>
);
}
Testing Offline Capabilities
Offline functionality introduces unique testing challenges that require specialized approaches.
Automated Testing Strategies
Comprehensive testing of offline functionality should include:
// Example Jest test for offline behavior
describe("Task Manager Offline Functionality", () => {
beforeEach(async () => {
// Set up a clean database
await db.tasks.clear();
// Mock offline state
networkMock.setOffline();
});
afterEach(() => {
// Reset network state
networkMock.reset();
});
test("should save task while offline and sync when online", async () => {
// Create a task while offline
const { getByText, getByTestId } = render(<TaskManager />);
fireEvent.changeText(getByTestId("task-input"), "Test offline task");
fireEvent.press(getByText("Add Task"));
// Verify task is in local database
const localTasks = await db.tasks.toArray();
expect(localTasks).toHaveLength(1);
expect(localTasks[0].title).toBe("Test offline task");
expect(localTasks[0].syncStatus).toBe("pending");
// Verify task appears in UI with offline indicator
expect(getByTestId("task-item-0")).toHaveTextContent("Test offline task");
expect(getByTestId("sync-status-0")).toHaveTextContent("Offline");
// Simulate coming online
networkMock.setOnline();
// Wait for sync to complete
await waitFor(() => {
expect(getByTestId("sync-status-0")).toHaveTextContent("Synced");
});
// Verify server received the task
expect(apiMock.createTask).toHaveBeenCalledWith(
expect.objectContaining({
title: "Test offline task",
})
);
// Verify local database updated sync status
const syncedTasks = await db.tasks.toArray();
expect(syncedTasks[0].syncStatus).toBe("synced");
});
});
Network Condition Simulation
Testing under various network conditions is essential:
// Configurable network condition simulator
class NetworkSimulator {
constructor() {
this.interceptor = null;
this.currentConditions = {
online: true,
latency: 0,
packetLoss: 0,
bandwidth: Infinity,
};
}
setup() {
// Set up network interception (implementation varies by platform)
this.interceptor = setupInterceptor();
this.interceptor.use((request) => {
// If offline, reject all requests
if (!this.currentConditions.online) {
return Promise.reject(new Error("Network request failed"));
}
// Apply latency simulation
const latency = this.getRandomizedLatency();
// Apply packet loss simulation
if (Math.random() < this.currentConditions.packetLoss) {
return new Promise((_, reject) => {
setTimeout(() => {
reject(new Error("Network request failed"));
}, latency);
});
}
// Apply bandwidth limitation by adjusting response time
// based on request/response size
return new Promise((resolve) => {
setTimeout(() => {
resolve();
}, this.calculateResponseTime(request));
});
});
}
setConditions(conditions) {
this.currentConditions = {
...this.currentConditions,
...conditions,
};
}
// Simulate intermittent connectivity
simulateIntermittentConnectivity(intervalMs = 5000) {
this.intermittentTimer = setInterval(() => {
this.setConditions({ online: !this.currentConditions.online });
}, intervalMs);
}
// Other helper methods for simulation
getRandomizedLatency() {
/* implementation */
}
calculateResponseTime(request) {
/* implementation */
}
cleanup() {
if (this.intermittentTimer) {
clearInterval(this.intermittentTimer);
}
if (this.interceptor) {
this.interceptor.restore();
}
}
}
Real-World Implementation Examples
To illustrate offline-first principles in action, let's examine implementations across popular frameworks.
React Native with Offline Support
A complete data management solution for React Native:
// src/services/database.js
import { Database } from "@nozbe/watermelondb";
import SQLiteAdapter from "@nozbe/watermelondb/adapters/sqlite";
import { synchronize } from "@nozbe/watermelondb/sync";
import { taskSchema, projectSchema } from "./schema";
import { Task, Project } from "./models";
import NetInfo from "@react-native-community/netinfo";
// Initialize database
const adapter = new SQLiteAdapter({
schema: [taskSchema, projectSchema],
// Optional migrations
migrations: [],
// Recommended settings for mobile
jsi: true,
onSetUpError: (error) => {
console.error("Database setup error:", error);
},
});
export const database = new Database({
adapter,
modelClasses: [Task, Project],
});
// Sync engine
export const syncDatabase = async () => {
// Check if we're online
const netState = await NetInfo.fetch();
if (!netState.isConnected) return false;
try {
await synchronize({
database,
pullChanges: async ({ lastPulledAt }) => {
// Pull changes from server
const timestamp = lastPulledAt
? new Date(lastPulledAt).toISOString()
: null;
const response = await fetch(
`https://api.example.com/sync?since=${timestamp}`
);
if (!response.ok) throw new Error(response.statusText);
const { changes, timestamp: serverTimestamp } = await response.json();
return { changes, timestamp: serverTimestamp };
},
pushChanges: async ({ changes }) => {
// Send local changes to server
const response = await fetch("https://api.example.com/sync", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ changes }),
});
if (!response.ok) throw new Error(response.statusText);
},
migrationsEnabledAtVersion: 1,
});
return true;
} catch (error) {
console.error("Sync failed:", error);
return false;
}
};
// Start background sync when app comes online
NetInfo.addEventListener((state) => {
if (state.isConnected) {
syncDatabase();
}
});
Flutter Offline-First Architecture
A complete offline-first infrastructure for Flutter:
// lib/data/repository/task_repository.dart
import 'package:drift/drift.dart';
import '../database/app_database.dart';
import '../network/api_client.dart';
import '../network/connectivity_service.dart';
import '../models/task.dart';
class TaskRepository {
final AppDatabase _database;
final ApiClient _apiClient;
final ConnectivityService _connectivity;
TaskRepository(this._database, this._apiClient, this._connectivity) {
// Initialize sync when connectivity changes
_connectivity.connectionStream.listen((connected) {
if (connected) _syncTasks();
});
}
// Create a task (works offline)
Future<Task> createTask(String title, String description) async {
final id = DateTime.now().millisecondsSinceEpoch.toString();
final task = TasksCompanion.insert(
id: Value(id),
title: title,
description: Value(description),
createdAt: Value(DateTime.now()),
syncStatus: const Value('pending'),
);
await _database.into(_database.tasks).insert(task);
// Try to sync immediately if online
if (await _connectivity.isConnected) {
_syncTasks();
}
return Task(
id: id,
title: title,
description: description,
createdAt: DateTime.now(),
syncStatus: 'pending',
);
}
// Get all tasks (works offline)
Stream<List<Task>> watchTasks() {
return _database.select(_database.tasks)
.watch()
.map((rows) => rows.map((row) => Task.fromDatabase(row)).toList());
}
// Sync tasks with server
Future<void> _syncTasks() async {
try {
// Get pending tasks
final pendingTasks = await _database.select(_database.tasks)
.where((t) => t.syncStatus.equals('pending'))
.get();
// Push pending tasks to server
for (final task in pendingTasks) {
try {
final response = await _apiClient.createTask(Task.fromDatabase(task));
// Update local record with server ID and status
await _database.update(_database.tasks)
.replace(TasksCompanion(
id: Value(task.id),
syncStatus: const Value('synced'),
serverUpdatedAt: Value(DateTime.now()),
));
} catch (e) {
// If server error (not connectivity), mark as failed
if (!await _connectivity.isConnected) continue;
await _database.update(_database.tasks)
.replace(TasksCompanion(
id: Value(task.id),
syncStatus: const Value('error'),
syncError: Value(e.toString()),
));
}
}
// Pull changes from server
final lastSync = await _getLastSyncTimestamp();
final serverTasks = await _apiClient.getTasks(lastSync);
// Update local database with server changes
await _database.transaction(() async {
for (final serverTask in serverTasks) {
// Check if task exists locally
final localTask = await (_database.select(_database.tasks)
.where((t) => t.id.equals(serverTask.id))
.getSingleOrNull());
if (localTask != null) {
// Skip if local version is pending (local changes take precedence)
if (localTask.syncStatus == 'pending') continue;
// Update existing task
await _database.update(_database.tasks)
.replace(TasksCompanion(
id: Value(serverTask.id),
title: Value(serverTask.title),
description: Value(serverTask.description),
syncStatus: const Value('synced'),
serverUpdatedAt: Value(DateTime.now()),
));
} else {
// Insert new task from server
await _database.into(_database.tasks).insert(
TasksCompanion.insert(
id: Value(serverTask.id),
title: serverTask.title,
description: Value(serverTask.description),
createdAt: Value(serverTask.createdAt),
syncStatus: const Value('synced'),
serverUpdatedAt: Value(DateTime.now()),
)
);
}
}
});
// Update last sync timestamp
await _setLastSyncTimestamp(DateTime.now());
} catch (e) {
print('Sync error: $e');
}
}
Future<DateTime?> _getLastSyncTimestamp() async {
final prefs = await _database.getPreferences();
final timestamp = prefs.getString('lastTaskSync');
return timestamp != null ? DateTime.parse(timestamp) : null;
}
Future<void> _setLastSyncTimestamp(DateTime timestamp) async {
final prefs = await _database.getPreferences();
await prefs.setString('lastTaskSync', timestamp.toIso8601String());
}
}
Progressive Web App with Offline Support
Web applications can also adopt offline-first principles:
// service-worker.js
const CACHE_NAME = "offline-app-v1";
const OFFLINE_URL = "offline.html";
// Assets to cache on install
const ASSETS_TO_CACHE = [
"/",
"/index.html",
"/styles/main.css",
"/scripts/app.js",
"/images/logo.png",
OFFLINE_URL,
];
// Install event - cache key assets
self.addEventListener("install", (event) => {
event.waitUntil(
caches
.open(CACHE_NAME)
.then((cache) => cache.addAll(ASSETS_TO_CACHE))
.then(() => self.skipWaiting())
);
});
// Activate event - clean up old caches
self.addEventListener("activate", (event) => {
event.waitUntil(
caches
.keys()
.then((cacheNames) => {
return Promise.all(
cacheNames
.filter((cacheName) => {
return cacheName !== CACHE_NAME;
})
.map((cacheName) => {
return caches.delete(cacheName);
})
);
})
.then(() => self.clients.claim())
);
});
// Fetch event - serve from cache, fall back to network
self.addEventListener("fetch", (event) => {
// Skip non-GET requests and browser extensions
if (
event.request.method !== "GET" ||
!event.request.url.startsWith(self.location.origin)
) {
return;
}
// API requests - network-first strategy
if (event.request.url.includes("/api/")) {
event.respondWith(
fetch(event.request)
.then((response) => {
// Clone the response and cache it for offline access
const clonedResponse = response.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, clonedResponse);
});
return response;
})
.catch(() => {
// If network fails, try cache
return caches.match(event.request).then((cachedResponse) => {
if (cachedResponse) {
return cachedResponse;
}
// If not in cache, show offline page
return caches.match(OFFLINE_URL);
});
})
);
return;
}
// Static assets - cache-first strategy
event.respondWith(
caches.match(event.request).then((cachedResponse) => {
if (cachedResponse) {
return cachedResponse;
}
// Not in cache, get from network
return fetch(event.request)
.then((response) => {
// Clone the response and cache it
const clonedResponse = response.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, clonedResponse);
});
return response;
})
.catch(() => {
// For HTML pages, show offline page
if (event.request.headers.get("Accept").includes("text/html")) {
return caches.match(OFFLINE_URL);
}
// For other resources, fail gracefully
return new Response("Resource not available offline", {
status: 503,
statusText: "Service Unavailable",
headers: new Headers({
"Content-Type": "text/plain",
}),
});
});
})
);
});
// Background sync for offline actions
self.addEventListener("sync", (event) => {
if (event.tag === "sync-tasks") {
event.waitUntil(syncTasks());
}
});
// Function to sync pending tasks
async function syncTasks() {
const db = await openDatabase();
const tx = db.transaction("pendingActions", "readonly");
const store = tx.objectStore("pendingActions");
const actions = await store.getAll();
for (const action of actions) {
try {
// Attempt to send to server
const response = await fetch("/api/tasks", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(action.data),
});
if (response.ok) {
// If successful, remove from pending actions
const deleteTx = db.transaction("pendingActions", "readwrite");
const deleteStore = deleteTx.objectStore("pendingActions");
await deleteStore.delete(action.id);
}
} catch (error) {
console.error("Failed to sync:", error);
// Will retry on next sync event
}
}
}
// Helper to open IndexedDB
function openDatabase() {
return new Promise((resolve, reject) => {
const request = indexedDB.open("offlineAppDB", 1);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);
request.onupgradeneeded = () => {
const db = request.result;
db.createObjectStore("pendingActions", { keyPath: "id" });
};
});
}
Advanced Topics in Offline-First Development
Beyond the basics, several advanced topics warrant consideration for robust offline applications.
Security Considerations
Offline-first applications raise unique security challenges:
// Secure local storage with encryption
import CryptoJS from "crypto-js";
import SecureStore from "expo-secure-store";
class SecureStorage {
// Get encryption key (stored securely)
async getEncryptionKey() {
let key = await SecureStore.getItemAsync("encryptionKey");
if (!key) {
// Generate new key if none exists
key = CryptoJS.lib.WordArray.random(256 / 8).toString();
await SecureStore.setItemAsync("encryptionKey", key);
}
return key;
}
// Encrypt and store data
async setItem(key, value) {
const encryptionKey = await this.getEncryptionKey();
// Encrypt data
const encryptedData = CryptoJS.AES.encrypt(
JSON.stringify(value),
encryptionKey
).toString();
// Store encrypted data
await AsyncStorage.setItem(key, encryptedData);
}
// Retrieve and decrypt data
async getItem(key) {
const encryptedData = await AsyncStorage.getItem(key);
if (!encryptedData) return null;
const encryptionKey = await this.getEncryptionKey();
// Decrypt data
const decryptedBytes = CryptoJS.AES.decrypt(encryptedData, encryptionKey);
try {
return JSON.parse(decryptedBytes.toString(CryptoJS.enc.Utf8));
} catch (e) {
console.error("Failed to decrypt data:", e);
return null;
}
}
// Secure removal
async removeItem(key) {
await AsyncStorage.removeItem(key);
}
}
Data Caching Policies
Intelligent caching strategies optimize both offline capability and performance:
// Advanced caching policy manager
class CachingManager {
constructor(options = {}) {
this.policies = {
// Default policy - cache for 1 hour
default: {
maxAge: 60 * 60 * 1000,
staleWhileRevalidate: true,
},
// Override with specific policies
...options.policies,
};
// Initialize database
this.db = options.database;
}
async getItem(key, fetchFunction, policyName = "default") {
const policy = this.policies[policyName] || this.policies.default;
// Try to get from cache
const cachedItem = await this.db.cacheItems.get(key);
const now = Date.now();
// If item exists and is not expired, return it
if (cachedItem && now - cachedItem.timestamp < policy.maxAge) {
return cachedItem.data;
}
// If item exists but is stale, we might return it while revalidating
if (cachedItem && policy.staleWhileRevalidate) {
// Schedule revalidation in background
this.revalidateItem(key, fetchFunction, policyName);
return cachedItem.data;
}
// If no valid cached item, fetch fresh data
try {
const freshData = await fetchFunction();
// Save to cache
await this.db.cacheItems.put({
key,
data: freshData,
timestamp: Date.now(),
policy: policyName,
});
return freshData;
} catch (error) {
// If network fails but we have stale data, return it
if (cachedItem) {
return cachedItem.data;
}
// No cached data and network failed
throw error;
}
}
async revalidateItem(key, fetchFunction, policyName) {
try {
const freshData = await fetchFunction();
// Update cache in background
await this.db.cacheItems.put({
key,
data: freshData,
timestamp: Date.now(),
policy: policyName,
});
return true;
} catch (error) {
console.warn(`Background revalidation failed for ${key}:`, error);
return false;
}
}
// Clear expired items
async purgeExpired() {
const now = Date.now();
const items = await this.db.cacheItems.toArray();
const expiredItems = items.filter((item) => {
const policy = this.policies[item.policy] || this.policies.default;
return now - item.timestamp > policy.maxAge;
});
await Promise.all(
expiredItems.map((item) => this.db.cacheItems.delete(item.key))
);
return expiredItems.length;
}
// Set storage quota limits
async enforceQuota(maxSizeBytes) {
if (!maxSizeBytes) return;
// Get all items sorted by timestamp (oldest first)
const items = await this.db.cacheItems.orderBy("timestamp").toArray();
// Calculate total size
let totalSize = 0;
for (const item of items) {
// Approximate size using JSON serialization
totalSize += JSON.stringify(item).length;
}
// If within quota, we're done
if (totalSize <= maxSizeBytes) return;
// Otherwise, remove oldest items until we're under quota
let index = 0;
while (totalSize > maxSizeBytes && index < items.length) {
const item = items[index++];
const itemSize = JSON.stringify(item).length;
await this.db.cacheItems.delete(item.key);
totalSize -= itemSize;
}
}
}
Conclusion: Building Resilient Mobile Experiences
The offline-first approach represents a fundamental shift in how we conceptualize mobile applications—moving from connectivity-dependent to resilient, locally capable software. As network conditions continue to vary wildly across global markets, this approach becomes not just a technical optimization but a core user experience consideration.
The key takeaways from this exploration of offline-first development include:
Start with local-first data: Design your data architecture assuming offline operation as the normal case, not the exception.
Implement transparent synchronization: Hide the complexity of data syncing from users while keeping them informed of status.
Design for all connectivity states: Create UIs that gracefully adapt to changing network conditions without disrupting user flow.
Test thorough disconnection scenarios: Simulate various network conditions to ensure robustness.
Consider platform-specific optimizations: Leverage the unique capabilities of each platform for optimal offline performance.
By embracing these principles, developers can create applications that work seamlessly across the full spectrum of connectivity environments, providing reliable, consistent experiences regardless of network availability. In an increasingly mobile-first world, offline capability isn't just a feature—it's an essential quality of truly resilient applications.
Ready to streamline your internal app distribution?
Start sharing your app builds with your team and clients today.
No app store reviews, no waiting times.