DEV Community

Mustafa ERBAY
Mustafa ERBAY

Posted on • Originally published at mustafaerbay.com.tr

Mobile Offline-First Synchronization: 3 Practical Challenges and

The offline-first architecture is an approach that fundamentally changes the user experience of mobile applications. It aims to provide seamless usage even without an internet connection. However, achieving this ideal scenario brings significant technical challenges, especially regarding data synchronization. I've repeatedly encountered such synchronization problems in my own Android spam blocker application and in a client's financial reporting mobile interface. In this post, I will detail three key challenges I faced and how I overcame them.

These challenges are not just theoretical knowledge; they are situations proven by direct field experience. The errors we encountered in real-world scenarios were sometimes simple configuration mistakes, but often they were the result of an architectural design flaw. Which synchronization strategy you choose depends on many factors, such as your database model, network conditions, and user behavior. Let's begin this in-depth examination.

1. Data Conflicts (Conflict Resolution)

The most fundamental problem of offline-first architecture is the simultaneous modification of the same piece of data on different devices, followed by an attempt to synchronize. This situation is called "data conflict" and, if not managed correctly, leads to data loss or inconsistency. In the mobile interface of a production ERP system, I experienced this problem deeply in a scenario where operators were entering data in the field and simultaneously updating the status of the same equipment.

For example, operator A might update a machine's operating hours from 10 to 12 hours on device A, while simultaneously operator B might update the same duration from 10 to 11 hours on device B. At the moment of synchronization, deciding which update should prevail becomes critical. Often, a simple "last write wins" strategy is used, but this can lead to the loss of a significant change made by the other operator.

💡 Conflict Resolution Strategies

Several different strategies are available to manage data conflicts:

  • Last Write Wins (LWW): The most recently saved data wins. Simple but can lead to data loss.
  • First Write Wins (FWW): The first saved data wins. The inverse of LWW, also carries a risk of data loss.
  • Client-Side Merging: When the application detects a conflict, it asks the user which change they want to keep. While it improves user experience, it can overwhelm the user in cases of numerous conflicts.
  • Server-Side Merging / Operational Transformation (OT): The server intelligently merges changes. This is the most complex but most robust solution. It is typically used in scenarios like collaborative text editing.
  • Conflict-Free Replicated Data Types (CRDTs): Data structures that are mathematically guaranteed to avoid conflicts. Requires a more complex architecture but provides high consistency.

In our case, since the data entered by operators in the production ERP was critical, the LWW strategy was insufficient. We kept a timestamp for the changes made by operators, and on the server side, we compared these timestamps to determine which change was newer. However, we encountered an additional problem: the clocks of the devices were not synchronized. This reduced the reliability of the timestamps.

To solve this problem, we started using a unique version number or a unique ID (UUID) for each data record. The server processed incoming updates based on these IDs. If two different records with the same ID arrived, it was flagged as a conflict, and a "conflict resolver" mechanism was triggered. This mechanism resolved the conflict either according to predefined rules (e.g., prioritizing based on an operator's role) or through manual intervention. This approach allowed us to minimize data loss.

# Simple example of last write wins and version-based conflict resolution (Python/FastAPI)

from datetime import datetime
from pydantic import BaseModel
from typing import Optional, Dict

class ProductionRecord(BaseModel):
    id: str
    machine_name: str
    work_duration_hours: float
    last_updated: datetime
    version: int

# Server-side data store (represented by a simple dict)
server_data_store: Dict[str, ProductionRecord] = {}

def update_record(new_record: ProductionRecord):
    existing_record = server_data_store.get(new_record.id)

    if not existing_record:
        # If no record exists, add it directly
        server_data_store[new_record.id] = new_record
        return True, "Record created successfully."

    if new_record.version > existing_record.version:
        # If the new record is newer, update
        server_data_store[new_record.id] = new_record
        return True, "Record updated successfully."
    elif new_record.version == existing_record.version:
        # Same version, last write wins (or another logic)
        if new_record.last_updated > existing_record.last_updated:
            server_data_store[new_record.id] = new_record
            return True, "Record updated (same version, newer timestamp)."
        else:
            return False, "Conflict: Older timestamp for same version."
    else:
        # Incoming record is older
        return False, f"Conflict: Incoming record version ({new_record.version}) is older than current ({existing_record.version})."

# Example Usage
record1 = ProductionRecord(id="machine-123", machine_name="CNC Lathe", work_duration_hours=10.5, last_updated=datetime(2023, 10, 27, 9, 0), version=1)
update_record(record1)

record2_update = ProductionRecord(id="machine-123", machine_name="CNC Lathe", work_duration_hours=11.0, last_updated=datetime(2023, 10, 27, 9, 5), version=2)
update_record(record2_update)

record3_conflict = ProductionRecord(id="machine-123", machine_name="CNC Lathe", work_duration_hours=11.5, last_updated=datetime(2023, 10, 27, 8, 55), version=1) # Older version
result, message = update_record(record3_conflict)
print(f"Result: {result}, Message: {message}")

record4_same_version_older_ts = ProductionRecord(id="machine-123", machine_name="CNC Lathe", work_duration_hours=11.2, last_updated=datetime(2023, 10, 27, 9, 3), version=2) # Same version, older timestamp
result, message = update_record(record4_same_version_older_ts)
print(f"Result: {result}, Message: {message}")
Enter fullscreen mode Exit fullscreen mode

These complex scenarios demonstrate how sensitive data synchronization is in mobile applications. It requires not only writing code but also a deep understanding of business logic and user behavior.

2. Background Synchronization and Battery Consumption

Continuously running background processes on mobile devices directly affect battery life. In an offline-first architecture, regular synchronizations are necessary to keep data up-to-date. However, when and how to trigger these synchronizations requires a critical balance in terms of user experience and device performance. In my Android spam blocker application, I had set up a blacklist and whitelist synchronization mechanism to block calls from unknown numbers. Keeping these lists current was vital for the user.

If synchronization is performed too frequently, it rapidly drains the device's battery and can exceed mobile data quotas. Conversely, if performed too infrequently, users might have to work with old or incorrect data, undermining the core purpose of the offline-first architecture. To strike this balance, strategic timing and event-based triggers are often used.

For example, synchronization can be triggered when the app comes to the foreground, when the screen is unlocked, when the device connects to Wi-Fi, or after a certain time interval has passed. However, these triggers also need to be managed intelligently. Constantly checking network status or running timers can still lead to battery consumption. Therefore, using the background task management APIs provided by operating systems (e.g., WorkManager on Android or BackgroundTasks on iOS) is the best approach. These APIs schedule tasks considering the system's overall efficiency.

ℹ️ Background Synchronization Optimizations

  • Operating System APIs: Optimize battery and resource consumption by using system APIs like Android WorkManager and iOS BackgroundTasks.
  • Network Status Check: Perform synchronizations only when an active and suitable network connection is available. Differentiate between Wi-Fi and mobile data usage.
  • Data Compression: Compress data before sending it for synchronization to reduce bandwidth usage and duration.
  • Incremental Sync: Synchronize only changed data. Instead of sending the full dataset every time, transfer only records that have changed since the last synchronization.
  • Background Limits: Adhere to the background process limits set by operating systems. Excessive resource usage can lead to your application being terminated.

In my spam blocker app, I adjusted the synchronization frequency based on the size of the list. If the list was small, it updated more frequently; if large, less frequently. Additionally, when the user manually pressed the "Update List" button, an immediate synchronization was triggered. Managing this complex logic with WorkManager significantly reduced battery consumption. Initially, the manually written timers caused me to notice that the device's battery drained 20% faster several times a day. After switching to WorkManager, this problem disappeared.

3. Data Integrity and Consistency

One of the biggest concerns in offline-first synchronization is that data must always be consistent and integral on both the client and server sides. Network outages, device shutdowns, or errors during synchronization can lead to data corruption or incomplete data. In a financial reporting application, an incorrect balance sheet or income statement report can lead to serious business consequences. Therefore, robust mechanisms must be established on both the client and server sides to ensure data integrity.

This is typically achieved through transaction management and validation steps. On the client side, data is first saved to a local database (e.g., SQLite, Realm, Hive). This saving process must be atomic, meaning it either succeeds completely or doesn't happen at all. Then, this data is queued and sent to the server when an appropriate network connection is available. Data sent to the server must be validated again before processing. On the server side, transactions must also be atomic, and a successful response should be returned to the client after they are completed.

⚠️ Data Inconsistency Scenarios

  • Partial Synchronization: If the network disconnects during synchronization, part of the data may reach the server, while another part may not.
  • Duplicate Records: In case of errors, the same data might be sent and saved multiple times.
  • Incorrect Ordering: Processing data packets in the wrong order can lead to unexpected results.
  • Data Corruption: Although rare, data packets can be corrupted during network transmission.

To prevent such issues, the concept of "Idempotency" is critical. An idempotent operation yields the same result when executed multiple times with the same input. To ensure idempotency in synchronization requests, each request is typically assigned a unique transaction ID. The server stores this ID and, if a request with the same ID arrives again, it rejects or ignores that request. This prevents data duplication if the same request is sent multiple times due to network errors.

In a project I developed with Flutter, I used a queue mechanism for synchronization after writing data to the local database. Each item in this queue had a unique UUID. Whenever a request was sent to the server, I sent this UUID and the data itself. On the server side, I tracked previously processed UUIDs using a cache mechanism like Redis. If an incoming UUID had already been processed, I ignored the request. This approach was quite effective in ensuring data consistency. Over approximately 6 months of use, I experienced no data duplication or inconsistency thanks to this mechanism.

// Simple server-side validation example with an Idempotency key (Node.js/Express)

const express = require('express');
const bodyParser = require('body-parser');
const redis = require('redis'); // Redis client

const app = express();
app.use(bodyParser.json());

// Initialize Redis client
const redisClient = redis.createClient();
redisClient.connect();

// Redis key format for storing processed requests
const idempotencyKeyPrefix = 'idempotency:';

// Data update function (actual database operations will go here)
async function processDataUpdate(data) {
    console.log('Processing data update:', data);
    // Database operations...
    return { success: true, message: 'Data processed successfully.' };
}

// POST /api/sync endpoint
app.post('/api/sync', async (req, res) => {
    const { idempotencyKey, data } = req.body;

    if (!idempotencyKey) {
        return res.status(400).json({ error: 'Idempotency key is required.' });
    }

    try {
        // Check for idempotency key from Redis
        const processed = await redisClient.get(`${idempotencyKeyPrefix}${idempotencyKey}`);

        if (processed) {
            // Already processed, return 200 OK but do not process again
            console.log(`Idempotency key ${idempotencyKey} already processed.`);
            return res.status(200).json({ message: 'Request already processed.' });
        }

        // Perform the operation
        const result = await processDataUpdate(data);

        // If the operation is successful, save the idempotency key to Redis (e.g., valid for 24 hours)
        await redisClient.set(`${idempotencyKeyPrefix}${idempotencyKey}`, 'processed', { EX: 24 * 60 * 60 });

        res.status(200).json(result);

    } catch (error) {
        console.error('Error processing sync request:', error);
        res.status(500).json({ error: 'Internal server error.' });
    }
});

const PORT = 3000;
app.listen(PORT, () => {
    console.log(`Sync server running on port ${PORT}`);
});
Enter fullscreen mode Exit fullscreen mode

These three challenges highlight the complexity of offline-first synchronization in mobile applications. However, with the right strategies and careful architectural design, these challenges can be overcome. As always, understanding the trade-offs and choosing the solution best suited to your project's specific requirements is essential.

In conclusion, offline-first synchronization in mobile applications is a powerful approach that improves user experience. However, challenges such as data conflicts, battery consumption, and data integrity require careful planning and implementation. The practical solutions and experiences I've shared in this post will shed light on the problems you might encounter in this area. Remember, the best synchronization is the one that works seamlessly without the user even noticing.

Top comments (0)