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

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

Jan Thalheim
Jan Thalheim
22 min read

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

  1. Local-first data management: Store essential data locally and treat the server as a synchronization point rather than the primary data source.
  2. Progressive enhancement: Core functionality works offline, with additional features enabled when connectivity is available.
  3. Transparent synchronization: Data transfers occur in the background without disrupting the user experience.
  4. Optimistic UI updates: Interface reflects user actions immediately, even before server confirmation.
  5. 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:

  1. Start with local-first data: Design your data architecture assuming offline operation as the normal case, not the exception.

  2. Implement transparent synchronization: Hide the complexity of data syncing from users while keeping them informed of status.

  3. Design for all connectivity states: Create UIs that gracefully adapt to changing network conditions without disrupting user flow.

  4. Test thorough disconnection scenarios: Simulate various network conditions to ensure robustness.

  5. 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.