civil-and-structural-engineering
How to Create Offline-first Mobile Apps for Reliable User Access
Table of Contents
Introduction: Why Offline-First Matters in Modern Mobile Development
Mobile users expect apps to work instantly and reliably, regardless of network conditions. In many parts of the world, connectivity is intermittent, expensive, or completely unavailable. Even in well-connected environments, users frequently encounter dead zones (elevators, tunnels, rural areas) or run into data limits. An offline-first architecture addresses these pain points by making local data the primary source of truth and treating the network as an enhancement rather than a requirement. This approach not only improves user satisfaction but also increases app retention, reduces data costs, and enables functionality in scenarios where an internet connection is simply not an option.
For developers building with a modern headless CMS like Directus, creating an offline-first mobile app requires careful planning around data storage, synchronization, and conflict resolution. Directus provides a flexible API layer (REST and GraphQL), real-time capabilities, and webhook triggers that make it an excellent backend for offline-first applications. This guide will walk you through the core concepts, key components, and practical steps to build a robust offline-first mobile app using Directus as your data source.
Understanding Offline-First Architecture: Core Principles
Offline-first is more than just caching a few JSON responses. It is a design philosophy where the local device becomes a full participant in the data management lifecycle. The architecture is built on three fundamental pillars:
- Local-First Data Persistence: All user interactions and data modifications happen against a local database (e.g., SQLite, Realm, or IndexedDB). The app must function fully without any network call.
- Background Synchronization: Whenever connectivity is available, the app syncs local changes to the server and pulls remote updates back down. This sync must be reliable, efficient, and non-blocking for the user.
- Conflict Resolution Strategy: When the same data is modified on multiple devices or while offline, conflicts arise. A clear strategy (e.g., last-write-wins, manual merge, or CRDT-based) must be in place to prevent data loss.
Directus fits naturally into this model. Its API supports delta queries (e.g., ?filter[date_updated][_gt]=<timestamp>), allowing the client to fetch only what has changed since the last sync. Combined with webhooks and the built-in activity logging (revisions), developers can build efficient sync loops without polling the entire dataset.
Challenges Unique to Offline-First Mobile Apps
Before diving into implementation, it is important to acknowledge common pitfalls. Offline-first apps introduce complexity that many server-reliant applications never encounter:
- Idempotency: Offline operations must be idempotent. Re-syncing the same create or update action should not result in duplicate records or unintended side effects.
- Optimistic UI & Rollback: When a user performs an action offline, the UI should immediately reflect the change (optimistic update). If the sync later fails or conflicts, the app must gracefully roll back the UI and notify the user.
- Data Integrity with Relations: Offline-modified records that reference other records (e.g., foreign keys) must handle cases where the referenced record hasn't synced yet. Temporary local IDs (UUIDs generated on-device) are essential.
- Battery & Network Awareness: Background sync should respect Doze mode (Android) and low-power modes (iOS). Excessive sync attempts can drain battery and frustrate users.
- Security & Authentication: Offline authentication tokens must be stored securely (Keychain, EncryptedSharedPreferences). The sync layer should ensure that expired or revoked tokens prevent data exfiltration.
Key Components of an Offline-First App with Directus
Building a production-ready offline-first mobile app involves multiple layers. Below are the essential components and how Directus supports each one.
1. Local Storage Engine
The local database is the heart of the app. You need an engine capable of high-performance reads and writes, and ideally one that supports relational data modeling. Popular choices include:
- SQLite (via libraries like realm or room): Excellent for mobile platforms; supports complex queries, indexes, and ACID transactions.
- IndexedDB (for PWAs or WebView‑based apps): Built into modern browsers, but limited query capabilities compared to SQLite.
- Firebase Firestore (local persistence): Provides offline support out of the box, but vendor lock-in and cost must be considered.
With Directus, the local schema should mirror the Directus collections you intend to sync. However, you may add extra local-only fields such as sync_status, local_created_at, and last_synced_at to track synchronization state.
2. Synchronization Engine
The sync engine manages the bidirectional flow of data. It must handle:
- Initial Bulk Load: Download all data when the app is first installed (or after a reset). Use Directus paginated endpoints with
?limitand?offsetto handle large datasets. - Delta Sync: After the initial load, fetch only records that changed since the last sync timestamp. Use Directus
?filter[date_updated][_gt]=2023-01-01T00:00:00Zand include related fields as needed. - Local Changes Upload: Send locally created, updated, or deleted records to Directus in batch. Use the Directus REST API for one-by-one or bulk operations. Ensure each request includes a unique
X-Request-Idheader for idempotency. - Conflict Detection & Resolution: When the server returns a conflict (HTTP 409) or a different version than expected, the engine must either automatically resolve (e.g., last-write-wins) or present the user with options.
Directus provides a robust activity and revisions endpoint that can be leveraged to track changes. Instead of polling full collections, you can query the activity log for changes since a given timestamp and then fetch only the affected items.
3. Conflict Resolution Strategies
Conflicts occur when the same record gets modified concurrently on the server and on a local device, or on two local devices before either syncs. Common strategies:
- Last-Write-Wins (LWW): The most recent timestamp (based on
date_updated) wins. Simple but can overwrite user intent. - First-Write-Wins: The first version that reaches the server persists; subsequent sync attempts must merge or be rejected.
- Manual Merge: The user is presented with both versions and must choose or combine them. This is more complex but avoids data loss.
- CRDT (Conflict-free Replicated Data Types): Advanced mathematical structures that guarantee eventual consistency without conflicts. Overkill for most CMS-driven apps, but possible with libraries like Yjs or automerge.
For most Directus-based apps, LWW combined with a clear read-repair flow works well. Store date_updated from the server locally and compare it during sync. If the local version is newer, push it; if the server version is newer, pull it and handle overwrites.
4. Network State Management
Your app must detect connectivity changes in real time. Use platform APIs like the Network Information API (PWA) or native libraries (@react-native-community/netinfo for React Native, connectivity_plus for Flutter). When the network status changes:
- Going offline: Pause pending sync jobs, cancel outgoing requests, and show a visible indicator (e.g., a banner at the top).
- Coming online: Queue a sync cycle, reestablish WebSocket connections if used, and pull any new data from Directus.
- During sync: Show progress bars or subtle icons. Avoid blocking the user interface unless a conflict requires attention.
Directus also supports WebSockets for real-time subscriptions (via the graphql or rest endpoint with websocket upgrades). You can subscribe to changes in specific collections or items and update the local cache automatically. This reduces the need for periodic polling and makes the app feel instant.
Implementing Offline Capabilities: A Step-by-Step Guide
Below is a practical workflow for adding offline-first behavior to a mobile app backed by Directus. We'll assume a React Native app using SQLite via WatermelonDB (a high-performance SQLite-based reactive database), but the principles translate to Flutter, SwiftUI, or PWAs.
Step 1: Design Your Data Model
Map your Directus collections to local database tables. Include extra metadata fields for sync control:
_syncStatus(enum: created, updated, deleted, synced)_lastSyncedAt(timestamp)_localId(UUID generated on device)
For every record in Directus, keep the id field as the primary key. For new offline-created records, generate a UUID locally and later map it to the server-generated ID after sync.
Step 2: Implement Initial Bulk Sync
When the user logs in or the app is freshly installed, fetch all relevant data from Directus. Use the export endpoint or paginated GET requests. Insert each record into the local SQLite database, setting _syncStatus = 'synced' and _lastSyncedAt to the current server timestamp. If the dataset is large (thousands of records), stream the responses in chunks and use batched inserts with transactions to avoid blocking the UI.
Step 3: Enable Local Writes with Optimistic UI
When a user creates, updates, or deletes a record, immediately change the local database and update the UI. Set _syncStatus to 'created' or 'updated'. For deletes, soft-delete locally by adding a _deleted flag (or move the record to a separate tombstone table). Do not wait for server confirmation. This makes the app feel responsive even on a slow connection.
Step 4: Build the Sync Engine
Create a dedicated sync service that runs periodically (e.g., every 3 minutes) and is triggered by network state changes. The sync engine performs three operations in this order:
- Upload local changes: Query all records where
_syncStatus != 'synced'. For each, call the appropriate Directus API (POST for create, PATCH for update, DELETE for delete). On success, update_syncStatusto'synced'and store the server-provideddate_updated. On 409 conflict, apply your resolution strategy (e.g., LWW: overwrite local with server data). On failure (network error), leave status as is and retry next cycle. - Fetch server changes: Call Directus with
?filter[date_updated][_gt]=${lastSyncTimestamp}. For each returned record, check if the local_syncStatusis 'synced' or 'updated'. If it's 'synced' and the server version is newer, overwrite the local record. If it's 'updated' (i.e., local changes pending), you have a conflict—handle accordingly. - Handle deletions: Directus soft-deletes (or hard-deletes) also need tracking. Either implement a tombstone mechanism or query the activity log for delete actions since last sync. When a record is deleted on the server and not modified locally, remove it from the local database.
Step 5: Handle UI Feedback for Sync Status
Users should always know whether their data is saved and synced. Use subtle indicators:
- A green checkmark next to synced items.
- A spinning icon next to pending sync items.
- A red exclamation mark if sync fails after multiple attempts.
- Global banner at top: "Offline – changes will sync when connected."
Avoid showing error dialogs for transient sync failures. Log errors and retry automatically. Only alert the user if a manual conflict resolution is required (e.g., two users edited the same field).
Step 6: Optimize for Performance and Battery
- Batch API calls: Directus supports batch endpoints (
PATCH /items/articleswith array of objects) to update multiple records in a single HTTP request. Use this during upload to reduce network overhead. - Throttle sync frequency: On cellular connections, increase the interval (e.g., 5 minutes). On Wi-Fi, sync more frequently.
- Use WebSocket subscriptions: Instead of polling for server changes, subscribe to changes via Directus WebSocket. This ensures instant updates and reduces battery drain from repeated HTTP requests.
- Lazy load large assets: Images and files should not be cached locally by default unless explicitly requested. Use CDN URLs and cache-on-demand strategies.
Tools and Frameworks for Offline-First with Directus
The following tools complement Directus when building offline-first mobile apps:
- WatermelonDB – Reactive, SQLite-based database for React Native with built-in sync adapter (documentation). Its sync protocol can be adapted to work with Directus API.
- Realm (MongoDB Mobile) – Object-oriented, edge-optimized database; supports Live Queries and automatic sync via MongoDB Realm (paid). Can also be used with custom REST API sync using Directus.
- SQLDelight (Flutter / Kotlin Multiplatform) – Generates type-safe Kotlin (and other platforms) from SQL statements; works well with Directus data.
- Directus SDK – Official TypeScript SDK helps with typing and API calls; can be extended with offline queue logic.
- Workbox (PWAs) – Library for precaching and runtime caching strategies; integrates with Service Worker to cache Directus API responses.
Best Practices for a Reliable Offline-First Experience
Based on real-world deployments, keep these principles in mind:
- Design your data model with offline in mind from day one. Adding offline support later is much harder than building it in from the start. Use UUIDs for primary keys whenever possible to avoid ID collisions during offline creation.
- Always store a server timestamp. The
date_updatedfield in Directus is your best friend. Never rely on device time alone; sync timestamps can be out of sync across devices. - Handle media gracefully. Do not download all images offline. Instead, cache only what the user has viewed (via a CDN proxy) and provide placeholder images until the content syncs.
- Test offline scenarios thoroughly. Use tools like Charles Proxy or the device's airplane mode to simulate connectivity loss. Verify that the app does not crash, that UI updates correctly, and that sync resumes when back online.
- Implement a robust logging mechanism. Sync errors are often silent. Log sync attempts, conflicts, and failures to a remote service (e.g., Sentry, LogRocket) so you can debug issues in production.
- Provide a manual sync button. Even with automatic sync, give users the ability to force a sync (e.g., pull-to-refresh). This builds trust and lets them resolve conflicts on demand.
- Educate users about offline capabilities. When the app goes offline, show a friendly message: "You're offline. All changes will be saved and synced when you reconnect." Avoid technical jargon.
Directus-Specific Optimizations for Offline Sync
Directus offers several features that can streamline offline-first development:
- Revision History: Enable "Revisions" in your data model settings. This allows you to retrieve previous versions of an item and implement a rollback mechanism if a sync introduces bad data.
- Custom Endpoints & Hooks: Create a custom endpoint (e.g.,
/sync/pulland/sync/push) that bundles multiple operations into a single request, reducing roundtrips. Use hooks (likeactionhooks) to trigger server-side validation or conflict detection. - Webhooks: When a record is updated on the server (by another device, admin panel, or automation), a webhook can notify your mobile app's push notification service to trigger a background sync. This keeps the app updated without polling.
- Field-Level Permissions: Directus permissions apply at the field level. Your sync engine must respect these permissions. When syncing, only push fields that the user has write access to, and only pull fields they have read access to.
Conclusion
Building an offline-first mobile app with Directus is not a trivial task, but the payoff in user experience and reliability is substantial. By designing for local data persistence, implementing a robust sync engine, and leveraging Directus's built-in features like delta filters, WebSockets, and revision history, you can create applications that work flawlessly in good and bad network conditions alike.
Start small: enable offline reading first, then gradually add offline create/update capabilities. Each iteration will bring you closer to a fully resilient app. Remember that conflict resolution and user trust are the hardest parts to get right—invest time in testing and refining your sync logic. With a solid foundation, your offline-first Directus mobile app will be a tool that users can rely on anywhere, anytime.