Building Offline-First .NET MAUI Apps: SQLite, Sync, and Conflict Resolution

A practical guide to building production-ready offline-first .NET MAUI apps using SQLite for local storage, operation queuing, bidirectional sync engines, and conflict resolution strategies including last-write-wins, field-level merging, and user-prompted resolution.

Why Offline-First Is No Longer Optional for Mobile Apps

There's a stubborn myth that still floats around in mobile development circles: you can treat network connectivity as a given and handle the offline case as an afterthought. Just toss in a "No internet connection" dialog and call it a day, right? If you've ever watched a user lose an entire form of data because they stepped into an elevator, you know exactly how badly that thinking can backfire.

Offline-first architecture flips the whole script. Instead of building a connected app and bolting on offline support later, you design the application to work perfectly without any network at all. The local device becomes the primary source of truth. The UI is driven by local state in real time. And the network? It's an optimization — a way to synchronize, back up, and share data — not a prerequisite for basic functionality.

I've been building .NET MAUI apps for cross-platform projects for a while now, and honestly, the offline-first pattern becomes even more compelling with MAUI's single codebase across Android, iOS, macOS, and Windows. Your users might be on a train with spotty cell service, in a basement warehouse with no Wi-Fi, or wrestling with that hilariously unreliable hotel internet we all know too well. A well-built offline-first .NET MAUI app handles all of these gracefully — without the user even noticing.

This guide walks through a comprehensive, production-ready approach to building offline-first .NET MAUI applications. We'll cover local database setup with SQLite, the repository pattern, connectivity monitoring, operation queuing, synchronization strategies, and — the part that trips most people up — how to handle the conflicts that inevitably arise when the same data gets modified in multiple places.

Choosing Your SQLite Library for .NET MAUI

Before writing a single line of data access code, you need to pick your SQLite library. In the .NET MAUI ecosystem, you've got three primary options, each with distinct trade-offs.

sqlite-net-pcl

The sqlite-net-pcl package (currently at version 1.9.x) is the lightweight ORM that's been the community standard for years. It provides a simple, attribute-based mapping system and both synchronous and asynchronous APIs. If you want minimal ceremony and a small footprint, this is your pick.

// NuGet packages needed:
// sqlite-net-pcl
// SQLitePCLRaw.bundle_green

[Table("tasks")]
public class TaskItem
{
    [PrimaryKey, AutoIncrement]
    public int Id { get; set; }

    [MaxLength(250)]
    public string Title { get; set; } = string.Empty;

    public string Description { get; set; } = string.Empty;

    public bool IsCompleted { get; set; }

    public DateTime CreatedAt { get; set; }

    public DateTime UpdatedAt { get; set; }

    // Sync metadata
    [MaxLength(36)]
    public string? RemoteId { get; set; }

    public long Version { get; set; }

    public bool IsDirty { get; set; }

    public bool IsDeleted { get; set; }
}

Entity Framework Core with SQLite

If you're already comfortable with EF Core from your ASP.NET work, the Microsoft.EntityFrameworkCore.Sqlite package brings that same familiar experience to mobile. You get migrations, LINQ queries, change tracking, and relationship management. The trade-off is a heavier dependency and slightly more involved setup — but for data-intensive apps, EF Core's power is genuinely hard to beat.

// NuGet package: Microsoft.EntityFrameworkCore.Sqlite

public class AppDbContext : DbContext
{
    public DbSet<TaskItem> Tasks => Set<TaskItem>();
    public DbSet<SyncOperation> PendingSyncOperations => Set<SyncOperation>();

    public AppDbContext(DbContextOptions<AppDbContext> options) : base(options)
    {
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<TaskItem>(entity =>
        {
            entity.HasKey(e => e.Id);
            entity.HasIndex(e => e.RemoteId).IsUnique();
            entity.HasIndex(e => e.IsDirty);
            entity.Property(e => e.Title).HasMaxLength(250).IsRequired();
        });

        modelBuilder.Entity<SyncOperation>(entity =>
        {
            entity.HasKey(e => e.Id);
            entity.HasIndex(e => e.CreatedAt);
        });
    }
}

Microsoft.Data.Sqlite

If you prefer raw SQL and maximum control, Microsoft.Data.Sqlite is the lightweight ADO.NET provider. No ORM, no magic — just SQL queries against SQLite. This works well when you need fine-grained control over query performance or when working with complex schemas that don't map cleanly to an ORM.

For this article, we'll primarily use sqlite-net-pcl for its simplicity, while showing EF Core alternatives where they add meaningful value.

Setting Up the Local Database Layer

A robust local database layer needs more than just a connection string. You need proper initialization, WAL mode for concurrent read/write performance, and a clean abstraction that your ViewModels can consume without caring about SQLite internals.

Database Initialization Service

using SQLite;

public class LocalDatabase
{
    private SQLiteAsyncConnection? _database;
    private readonly string _dbPath;

    public LocalDatabase()
    {
        _dbPath = Path.Combine(
            FileSystem.AppDataDirectory, "app_data.db3");
    }

    private async Task<SQLiteAsyncConnection> GetConnectionAsync()
    {
        if (_database is not null)
            return _database;

        _database = new SQLiteAsyncConnection(_dbPath,
            SQLiteOpenFlags.ReadWrite |
            SQLiteOpenFlags.Create |
            SQLiteOpenFlags.SharedCache);

        // Enable Write-Ahead Logging for better concurrent performance
        await _database.EnableWriteAheadLoggingAsync();

        // Create tables
        await _database.CreateTableAsync<TaskItem>();
        await _database.CreateTableAsync<SyncOperation>();
        await _database.CreateTableAsync<SyncMetadata>();

        return _database;
    }

    public async Task<List<T>> GetAllAsync<T>() where T : new()
    {
        var db = await GetConnectionAsync();
        return await db.Table<T>().ToListAsync();
    }

    public async Task<T?> GetByIdAsync<T>(int id) where T : new()
    {
        var db = await GetConnectionAsync();
        return await db.FindAsync<T>(id);
    }

    public async Task<int> SaveAsync<T>(T item)
    {
        var db = await GetConnectionAsync();
        return await db.InsertOrReplaceAsync(item);
    }

    public async Task<int> DeleteAsync<T>(T item)
    {
        var db = await GetConnectionAsync();
        return await db.DeleteAsync(item);
    }
}

A few things worth noting here. First, we use lazy initialization — the database connection only gets created when it's first accessed. Second, we enable Write-Ahead Logging (WAL), which is critical for performance. WAL writes changes into a separate journal file first, allowing concurrent reads while a write is in progress. Without WAL, SQLite locks the entire database during writes, and that can cause noticeable UI stuttering when background sync operations are running.

Third, notice the SharedCache flag. This lets multiple connections share the same cache in memory, reducing memory usage when both your app logic and sync engine are accessing the database at the same time.

The Repository Pattern for Clean Data Access

Wrapping the raw database in a repository gives you a testable, swappable abstraction. Here's a generic repository that handles sync metadata automatically:

public interface IRepository<T> where T : class, ISyncable, new()
{
    Task<List<T>> GetAllAsync();
    Task<List<T>> GetActiveAsync(); // Excludes soft-deleted items
    Task<T?> GetByIdAsync(int id);
    Task<T?> GetByRemoteIdAsync(string remoteId);
    Task SaveAsync(T item);
    Task SoftDeleteAsync(int id);
    Task<List<T>> GetDirtyItemsAsync();
}

public interface ISyncable
{
    int Id { get; set; }
    string? RemoteId { get; set; }
    long Version { get; set; }
    bool IsDirty { get; set; }
    bool IsDeleted { get; set; }
    DateTime UpdatedAt { get; set; }
}

public class Repository<T> : IRepository<T> where T : class, ISyncable, new()
{
    private readonly LocalDatabase _db;

    public Repository(LocalDatabase db)
    {
        _db = db;
    }

    public async Task<List<T>> GetAllAsync()
    {
        return await _db.GetAllAsync<T>();
    }

    public async Task<List<T>> GetActiveAsync()
    {
        var db = await _db.GetConnectionAsync();
        return await db.Table<T>()
            .Where(x => !x.IsDeleted)
            .ToListAsync();
    }

    public async Task<T?> GetByIdAsync(int id)
    {
        return await _db.GetByIdAsync<T>(id);
    }

    public async Task<T?> GetByRemoteIdAsync(string remoteId)
    {
        var db = await _db.GetConnectionAsync();
        return await db.Table<T>()
            .FirstOrDefaultAsync(x => x.RemoteId == remoteId);
    }

    public async Task SaveAsync(T item)
    {
        item.IsDirty = true;
        item.UpdatedAt = DateTime.UtcNow;
        await _db.SaveAsync(item);
    }

    public async Task SoftDeleteAsync(int id)
    {
        var item = await GetByIdAsync(id);
        if (item is null) return;

        item.IsDeleted = true;
        item.IsDirty = true;
        item.UpdatedAt = DateTime.UtcNow;
        await _db.SaveAsync(item);
    }

    public async Task<List<T>> GetDirtyItemsAsync()
    {
        var db = await _db.GetConnectionAsync();
        return await db.Table<T>()
            .Where(x => x.IsDirty)
            .ToListAsync();
    }
}

The key design decision here is the soft delete pattern. When a user deletes an item locally, we don't actually remove it from the database — we mark it with IsDeleted = true and IsDirty = true. This ensures the deletion gets synchronized to the server. Only after a successful sync do we permanently purge soft-deleted records.

Monitoring Connectivity with IConnectivity

.NET MAUI provides the IConnectivity interface out of the box for monitoring network status. But raw connectivity detection is only half the battle — you need a service that intelligently manages transitions between online and offline states and fires the right actions at the right time.

public class ConnectivityService : IDisposable
{
    private readonly IConnectivity _connectivity;
    private bool _wasOnline;

    public event EventHandler<bool>? ConnectivityChanged;
    public bool IsOnline => _connectivity.NetworkAccess == NetworkAccess.Internet;

    public ConnectivityService(IConnectivity connectivity)
    {
        _connectivity = connectivity;
        _wasOnline = IsOnline;
        _connectivity.ConnectivityChanged += OnConnectivityChanged;
    }

    private void OnConnectivityChanged(object? sender, ConnectivityChangedEventArgs e)
    {
        var isNowOnline = e.NetworkAccess == NetworkAccess.Internet;

        // Only fire our event on actual state transitions
        if (isNowOnline != _wasOnline)
        {
            _wasOnline = isNowOnline;
            ConnectivityChanged?.Invoke(this, isNowOnline);
        }
    }

    public bool IsMeteredConnection()
    {
        var profiles = _connectivity.ConnectionProfiles;
        return profiles.Contains(ConnectionProfile.Cellular);
    }

    public void Dispose()
    {
        _connectivity.ConnectivityChanged -= OnConnectivityChanged;
    }
}

Notice the IsMeteredConnection() method. This matters more than you might think — you probably want to defer large sync operations when the user is on cellular data to avoid unexpected data charges. It's one of those small details that separates a polished app from a frustrating one.

The Operation Queue: Tracking Changes for Sync

When the device is offline, every create, update, and delete operation needs to be recorded so it can be replayed against the server when connectivity returns. This is where the operation queue comes in — it's essentially the bridge between local changes and remote synchronization.

[Table("sync_operations")]
public class SyncOperation
{
    [PrimaryKey, AutoIncrement]
    public int Id { get; set; }

    [MaxLength(50)]
    public string EntityType { get; set; } = string.Empty;

    public int EntityId { get; set; }

    [MaxLength(36)]
    public string? RemoteEntityId { get; set; }

    [MaxLength(10)]
    public string OperationType { get; set; } = string.Empty; // CREATE, UPDATE, DELETE

    // Serialized JSON of the entity at the time of the operation
    public string? Payload { get; set; }

    public DateTime CreatedAt { get; set; }

    public int RetryCount { get; set; }

    [MaxLength(500)]
    public string? LastError { get; set; }
}

public class OperationQueue
{
    private readonly LocalDatabase _db;

    public OperationQueue(LocalDatabase db)
    {
        _db = db;
    }

    public async Task EnqueueAsync(string entityType, int entityId,
        string? remoteId, string operationType, object? payload = null)
    {
        var operation = new SyncOperation
        {
            EntityType = entityType,
            EntityId = entityId,
            RemoteEntityId = remoteId,
            OperationType = operationType,
            Payload = payload is not null
                ? System.Text.Json.JsonSerializer.Serialize(payload)
                : null,
            CreatedAt = DateTime.UtcNow,
            RetryCount = 0
        };

        await _db.SaveAsync(operation);
    }

    public async Task<List<SyncOperation>> GetPendingOperationsAsync()
    {
        var db = await _db.GetConnectionAsync();
        return await db.Table<SyncOperation>()
            .OrderBy(x => x.CreatedAt)
            .ToListAsync();
    }

    public async Task MarkCompletedAsync(int operationId)
    {
        var db = await _db.GetConnectionAsync();
        await db.DeleteAsync<SyncOperation>(operationId);
    }

    public async Task MarkFailedAsync(int operationId, string error)
    {
        var operation = await _db.GetByIdAsync<SyncOperation>(operationId);
        if (operation is null) return;

        operation.RetryCount++;
        operation.LastError = error;
        await _db.SaveAsync(operation);
    }

    public async Task<int> GetPendingCountAsync()
    {
        var db = await _db.GetConnectionAsync();
        return await db.Table<SyncOperation>().CountAsync();
    }
}

The operation queue maintains chronological ordering, and that's not just a nice-to-have. Operations must be replayed in the same order they were performed locally. Think about it: if a user creates an item and then updates it while offline, the server needs to process the creation before the update — otherwise you'd be trying to update a record that doesn't exist yet.

We also track RetryCount and LastError. This prevents infinite retry loops for permanently failing operations. After a configurable number of retries, you can surface the error to the user or move the operation to a dead-letter queue for manual review.

Building the Sync Engine

The sync engine is really the heart of the entire offline-first architecture. It orchestrates the bidirectional flow of data between the local SQLite database and the remote API. Getting this right is non-trivial, but the pattern itself is pretty straightforward: push local changes first, then pull remote changes.

Why Push Before Pull?

This ordering is deliberate. By pushing local changes first, you ensure the server has the most current version of locally modified records before you pull down updates. This minimizes the window for conflicts and ensures that your pull operation receives data that already accounts for your latest changes.

public class SyncEngine
{
    private readonly OperationQueue _operationQueue;
    private readonly IApiClient _apiClient;
    private readonly LocalDatabase _db;
    private readonly ConnectivityService _connectivity;
    private readonly SemaphoreSlim _syncLock = new(1, 1);
    private bool _isSyncing;

    public event EventHandler<SyncProgressEventArgs>? SyncProgressChanged;
    public event EventHandler<SyncConflictEventArgs>? ConflictDetected;

    public SyncEngine(
        OperationQueue operationQueue,
        IApiClient apiClient,
        LocalDatabase db,
        ConnectivityService connectivity)
    {
        _operationQueue = operationQueue;
        _apiClient = apiClient;
        _db = db;
        _connectivity = connectivity;
    }

    public async Task<SyncResult> SynchronizeAsync(
        CancellationToken cancellationToken = default)
    {
        if (!_connectivity.IsOnline)
            return SyncResult.Offline();

        if (!await _syncLock.WaitAsync(0, cancellationToken))
            return SyncResult.AlreadyRunning();

        try
        {
            _isSyncing = true;
            var result = new SyncResult();

            // Phase 1: Push local changes to server
            RaiseSyncProgress("Pushing local changes...", 0);
            var pushResult = await PushChangesAsync(cancellationToken);
            result.PushedCount = pushResult.SuccessCount;
            result.PushErrors = pushResult.Errors;

            // Phase 2: Pull remote changes from server
            RaiseSyncProgress("Pulling remote changes...", 50);
            var pullResult = await PullChangesAsync(cancellationToken);
            result.PulledCount = pullResult.Count;

            // Phase 3: Purge successfully synced soft-deleted records
            RaiseSyncProgress("Cleaning up...", 90);
            await PurgeSyncedDeletionsAsync();

            RaiseSyncProgress("Sync complete", 100);
            result.IsSuccess = true;
            result.CompletedAt = DateTime.UtcNow;

            return result;
        }
        catch (OperationCanceledException)
        {
            return SyncResult.Cancelled();
        }
        finally
        {
            _isSyncing = false;
            _syncLock.Release();
        }
    }

    private async Task<PushResult> PushChangesAsync(
        CancellationToken cancellationToken)
    {
        var result = new PushResult();
        var operations = await _operationQueue.GetPendingOperationsAsync();

        for (int i = 0; i < operations.Count; i++)
        {
            cancellationToken.ThrowIfCancellationRequested();

            var op = operations[i];
            var progress = (int)((double)i / operations.Count * 50);
            RaiseSyncProgress(
                $"Pushing {op.OperationType} for {op.EntityType}...",
                progress);

            try
            {
                await ProcessOperationAsync(op, cancellationToken);
                await _operationQueue.MarkCompletedAsync(op.Id);
                result.SuccessCount++;
            }
            catch (ApiConflictException ex)
            {
                await HandleConflictAsync(op, ex);
                result.Errors.Add(
                    $"Conflict on {op.EntityType} {op.EntityId}: {ex.Message}");
            }
            catch (Exception ex) when (ex is not OperationCanceledException)
            {
                await _operationQueue.MarkFailedAsync(op.Id, ex.Message);

                if (op.RetryCount >= 5)
                {
                    result.Errors.Add(
                        $"Permanently failed {op.EntityType} " +
                        $"{op.EntityId}: {ex.Message}");
                }
            }
        }

        return result;
    }

    private async Task ProcessOperationAsync(
        SyncOperation op, CancellationToken cancellationToken)
    {
        switch (op.OperationType)
        {
            case "CREATE":
                var createResponse = await _apiClient.CreateAsync(
                    op.EntityType, op.Payload!, cancellationToken);
                // Update local record with server-assigned ID and version
                await UpdateLocalRecordWithRemoteIdAsync(
                    op.EntityType, op.EntityId,
                    createResponse.Id, createResponse.Version);
                break;

            case "UPDATE":
                await _apiClient.UpdateAsync(
                    op.EntityType, op.RemoteEntityId!,
                    op.Payload!, cancellationToken);
                break;

            case "DELETE":
                await _apiClient.DeleteAsync(
                    op.EntityType, op.RemoteEntityId!,
                    cancellationToken);
                break;
        }
    }

    private async Task<PullResult> PullChangesAsync(
        CancellationToken cancellationToken)
    {
        var result = new PullResult();
        var lastSyncTimestamp = await GetLastSyncTimestampAsync();

        // Request only records modified since our last sync
        var remoteChanges = await _apiClient.GetChangesAsync(
            lastSyncTimestamp, cancellationToken);

        foreach (var change in remoteChanges)
        {
            cancellationToken.ThrowIfCancellationRequested();

            await ApplyRemoteChangeAsync(change);
            result.Count++;
        }

        // Update the last sync timestamp
        await SetLastSyncTimestampAsync(DateTime.UtcNow);

        return result;
    }

    private async Task ApplyRemoteChangeAsync(RemoteChange change)
    {
        var db = await _db.GetConnectionAsync();

        switch (change.EntityType)
        {
            case "TaskItem":
                var existingTask = await db.Table<TaskItem>()
                    .FirstOrDefaultAsync(
                        x => x.RemoteId == change.RemoteId);

                if (change.IsDeleted)
                {
                    if (existingTask is not null)
                        await db.DeleteAsync(existingTask);
                    return;
                }

                var remoteTask = System.Text.Json.JsonSerializer
                    .Deserialize<TaskItem>(change.Data);

                if (remoteTask is null) return;

                if (existingTask is not null)
                {
                    // Check for local modifications
                    if (existingTask.IsDirty)
                    {
                        // Conflict! Local and remote both modified
                        await RaiseConflictAsync(existingTask, remoteTask);
                        return;
                    }

                    // No local changes — safe to overwrite
                    remoteTask.Id = existingTask.Id;
                    remoteTask.IsDirty = false;
                    await db.UpdateAsync(remoteTask);
                }
                else
                {
                    // New record from server
                    remoteTask.IsDirty = false;
                    await db.InsertAsync(remoteTask);
                }
                break;
        }
    }

    private async Task<DateTime> GetLastSyncTimestampAsync()
    {
        var db = await _db.GetConnectionAsync();
        var meta = await db.Table<SyncMetadata>()
            .FirstOrDefaultAsync(x => x.Key == "LastSyncTimestamp");
        return meta is not null
            ? DateTime.Parse(meta.Value)
            : DateTime.MinValue;
    }

    private async Task SetLastSyncTimestampAsync(DateTime timestamp)
    {
        var db = await _db.GetConnectionAsync();
        var meta = new SyncMetadata
        {
            Key = "LastSyncTimestamp",
            Value = timestamp.ToString("O")
        };
        await db.InsertOrReplaceAsync(meta);
    }

    private void RaiseSyncProgress(string message, int percentComplete)
    {
        SyncProgressChanged?.Invoke(this,
            new SyncProgressEventArgs(message, percentComplete));
    }
}

There's a lot happening in this class, so let me break down the critical design decisions:

  • SemaphoreSlim for concurrency control: The sync engine uses a semaphore to prevent concurrent sync operations. Without this guard, a timer-triggered sync could overlap with a user-initiated sync, leading to duplicate operations and data corruption. Not fun to debug.
  • Incremental sync with timestamps: We only pull records modified since our last successful sync. This is dramatically more efficient than pulling the entire dataset every time, and it becomes increasingly important as your data grows.
  • Retry with backoff: Failed operations stay in the queue with an incremented retry count. After 5 failures, we treat the operation as permanently failed — at that point, it probably requires manual intervention rather than yet another retry.
  • Progress reporting: The SyncProgressChanged event lets the UI show meaningful progress to the user. Transparency about sync status really does build trust.

Conflict Resolution Strategies

Conflicts are inevitable in any offline-first system. When two users modify the same record — or even when a single user modifies a record on two devices before syncing — someone's changes will be in contention. How you handle this essentially defines the user experience of your sync system.

Strategy 1: Last Write Wins (LWW)

The simplest approach: whichever change has the most recent timestamp wins. It's easy to implement and works reasonably well when conflicts are rare or when the data isn't highly sensitive.

public class LastWriteWinsResolver : IConflictResolver
{
    public Task<ConflictResolution> ResolveAsync<T>(
        T localItem, T remoteItem) where T : ISyncable
    {
        // Compare timestamps — most recent modification wins
        if (localItem.UpdatedAt >= remoteItem.UpdatedAt)
        {
            return Task.FromResult(
                ConflictResolution.KeepLocal);
        }

        return Task.FromResult(
            ConflictResolution.KeepRemote);
    }
}

The downside is obvious: the "loser" silently loses their changes with zero notification. For a personal to-do app, that's usually fine. For a medical records system? Absolutely unacceptable.

Strategy 2: Field-Level Merge

Instead of choosing one entire record over another, you can merge at the field level. If User A changed the title and User B changed the description, both changes get preserved:

public class FieldLevelMergeResolver : IConflictResolver
{
    public Task<ConflictResolution> ResolveAsync<T>(
        T localItem, T remoteItem) where T : ISyncable
    {
        var baseVersion = GetBaseVersion<T>(localItem.RemoteId);
        if (baseVersion is null)
            return Task.FromResult(ConflictResolution.KeepRemote);

        var mergedItem = MergeFields(baseVersion, localItem, remoteItem);

        return Task.FromResult(
            ConflictResolution.UseMerged(mergedItem));
    }

    private T MergeFields<T>(T baseItem, T localItem, T remoteItem)
    {
        var result = Activator.CreateInstance<T>();
        var properties = typeof(T).GetProperties()
            .Where(p => p.CanRead && p.CanWrite);

        foreach (var prop in properties)
        {
            var baseValue = prop.GetValue(baseItem);
            var localValue = prop.GetValue(localItem);
            var remoteValue = prop.GetValue(remoteItem);

            if (Equals(localValue, baseValue))
            {
                // Local didn't change this field — take remote
                prop.SetValue(result, remoteValue);
            }
            else if (Equals(remoteValue, baseValue))
            {
                // Remote didn't change this field — take local
                prop.SetValue(result, localValue);
            }
            else
            {
                // Both changed — pick the most recent one
                // for this specific field
                prop.SetValue(result,
                    ((ISyncable)localItem).UpdatedAt >=
                    ((ISyncable)remoteItem).UpdatedAt
                        ? localValue : remoteValue);
            }
        }

        return result;
    }
}

This approach does require storing a "base version" — the state of the record as it was when last synced. Three-way merging (base vs. local vs. remote) is the gold standard for field-level conflict resolution, and it's the same principle that powers Git's merge algorithm. If you've ever resolved a Git merge conflict, you already understand the concept intuitively.

Strategy 3: User-Prompted Resolution

For critical data, sometimes the right move is to just present both versions to the user and let them decide:

public class UserPromptedResolver : IConflictResolver
{
    private readonly IConflictUI _conflictUI;

    public UserPromptedResolver(IConflictUI conflictUI)
    {
        _conflictUI = conflictUI;
    }

    public async Task<ConflictResolution> ResolveAsync<T>(
        T localItem, T remoteItem) where T : ISyncable
    {
        // Show both versions to the user on the main thread
        var userChoice = await MainThread.InvokeOnMainThreadAsync(
            () => _conflictUI.ShowConflictDialogAsync(
                localItem, remoteItem));

        return userChoice switch
        {
            UserConflictChoice.KeepMine =>
                ConflictResolution.KeepLocal,
            UserConflictChoice.KeepTheirs =>
                ConflictResolution.KeepRemote,
            UserConflictChoice.KeepBoth =>
                ConflictResolution.DuplicateAndKeepBoth(
                    localItem, remoteItem),
            _ => ConflictResolution.KeepRemote
        };
    }
}

This is the most user-friendly approach for important data, but it does interrupt the sync flow. Use it selectively — for most entity types, an automated strategy works better, with user prompting reserved for high-value records where data loss isn't an option.

Registering Everything with Dependency Injection

All of these components need to be wired together in your MauiProgram.cs. Here's the complete DI registration:

public static class MauiProgram
{
    public static MauiApp CreateMauiApp()
    {
        var builder = MauiApp.CreateBuilder();
        builder
            .UseMauiApp<App>()
            .ConfigureFonts(fonts =>
            {
                fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
            });

        // Core data services
        builder.Services.AddSingleton<LocalDatabase>();
        builder.Services.AddSingleton<OperationQueue>();
        builder.Services.AddSingleton<ConnectivityService>();

        // Platform services
        builder.Services.AddSingleton<IConnectivity>(
            Connectivity.Current);

        // Repositories
        builder.Services.AddTransient<IRepository<TaskItem>,
            Repository<TaskItem>>();

        // Sync engine
        builder.Services.AddSingleton<IConflictResolver,
            LastWriteWinsResolver>();
        builder.Services.AddSingleton<SyncEngine>();

        // HTTP client for API communication
        builder.Services.AddHttpClient<IApiClient, ApiClient>(
            client =>
        {
            client.BaseAddress = new Uri("https://api.example.com/");
            client.DefaultRequestHeaders.Add("Accept",
                "application/json");
        });

        // ViewModels
        builder.Services.AddTransient<TaskListViewModel>();

        // Pages
        builder.Services.AddTransient<TaskListPage>();

        return builder.Build();
    }
}

Integrating Sync into the ViewModel Layer

This is where offline-first really comes together. Your ViewModel should always read from and write to the local database — never directly to the API. The sync engine handles server communication independently, and that separation is what makes the whole thing work.

public partial class TaskListViewModel : ObservableObject
{
    private readonly IRepository<TaskItem> _repository;
    private readonly OperationQueue _operationQueue;
    private readonly SyncEngine _syncEngine;
    private readonly ConnectivityService _connectivity;

    [ObservableProperty]
    private ObservableCollection<TaskItem> _tasks = new();

    [ObservableProperty]
    private bool _isSyncing;

    [ObservableProperty]
    private string _syncStatus = "Ready";

    [ObservableProperty]
    private int _pendingChanges;

    [ObservableProperty]
    private bool _isOffline;

    public TaskListViewModel(
        IRepository<TaskItem> repository,
        OperationQueue operationQueue,
        SyncEngine syncEngine,
        ConnectivityService connectivity)
    {
        _repository = repository;
        _operationQueue = operationQueue;
        _syncEngine = syncEngine;
        _connectivity = connectivity;

        _connectivity.ConnectivityChanged += OnConnectivityChanged;
        _syncEngine.SyncProgressChanged += OnSyncProgress;

        IsOffline = !_connectivity.IsOnline;
    }

    [RelayCommand]
    private async Task LoadTasksAsync()
    {
        // Always load from local database — instant, works offline
        var items = await _repository.GetActiveAsync();
        Tasks = new ObservableCollection<TaskItem>(items);
        PendingChanges = await _operationQueue.GetPendingCountAsync();
    }

    [RelayCommand]
    private async Task AddTaskAsync(string title)
    {
        var task = new TaskItem
        {
            Title = title,
            CreatedAt = DateTime.UtcNow,
            UpdatedAt = DateTime.UtcNow
        };

        // Save locally first — immediate feedback
        await _repository.SaveAsync(task);

        // Queue for sync
        await _operationQueue.EnqueueAsync(
            "TaskItem", task.Id, null, "CREATE", task);

        // Refresh the UI
        Tasks.Add(task);
        PendingChanges = await _operationQueue.GetPendingCountAsync();

        // Attempt immediate sync if online
        if (_connectivity.IsOnline)
        {
            await SyncAsync();
        }
    }

    [RelayCommand]
    private async Task CompleteTaskAsync(TaskItem task)
    {
        task.IsCompleted = true;
        await _repository.SaveAsync(task);

        await _operationQueue.EnqueueAsync(
            "TaskItem", task.Id, task.RemoteId, "UPDATE", task);

        PendingChanges = await _operationQueue.GetPendingCountAsync();

        if (_connectivity.IsOnline)
        {
            await SyncAsync();
        }
    }

    [RelayCommand]
    private async Task DeleteTaskAsync(TaskItem task)
    {
        await _repository.SoftDeleteAsync(task.Id);

        await _operationQueue.EnqueueAsync(
            "TaskItem", task.Id, task.RemoteId, "DELETE");

        Tasks.Remove(task);
        PendingChanges = await _operationQueue.GetPendingCountAsync();

        if (_connectivity.IsOnline)
        {
            await SyncAsync();
        }
    }

    [RelayCommand]
    private async Task SyncAsync()
    {
        if (IsSyncing) return;

        IsSyncing = true;
        try
        {
            var result = await _syncEngine.SynchronizeAsync();

            if (result.IsSuccess)
            {
                await LoadTasksAsync(); // Refresh with synced data
                SyncStatus = $"Synced {result.PushedCount} up, " +
                    $"{result.PulledCount} down";
            }
            else
            {
                SyncStatus = result.ErrorMessage ?? "Sync failed";
            }
        }
        finally
        {
            IsSyncing = false;
        }
    }

    private async void OnConnectivityChanged(object? sender, bool isOnline)
    {
        IsOffline = !isOnline;

        if (isOnline)
        {
            SyncStatus = "Back online — syncing...";
            await SyncAsync();
        }
        else
        {
            SyncStatus = "Offline — changes saved locally";
        }
    }

    private void OnSyncProgress(object? sender, SyncProgressEventArgs e)
    {
        MainThread.BeginInvokeOnMainThread(() =>
        {
            SyncStatus = e.Message;
        });
    }
}

The critical pattern here: every user action writes to the local database immediately and returns control to the UI. The user sees instant feedback regardless of network status. Sync happens opportunistically — either right away if you're online, or whenever connectivity comes back. That's the beauty of this architecture.

Building the Sync Status UI

Users need to know what's happening with their data. A subtle but informative sync status indicator goes a long way toward building confidence that nothing is being lost:

<!-- TaskListPage.xaml -->
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:vm="clr-namespace:MyApp.ViewModels"
             x:Class="MyApp.Views.TaskListPage"
             x:DataType="vm:TaskListViewModel">

    <Grid RowDefinitions="Auto,*,Auto">

        <!-- Offline banner -->
        <Frame Grid.Row="0"
               BackgroundColor="#FFF3CD"
               Padding="10"
               IsVisible="{Binding IsOffline}"
               CornerRadius="0">
            <HorizontalStackLayout HorizontalOptions="Center" Spacing="8">
                <Label Text="&#x26A0;" FontSize="16" />
                <Label Text="You are offline. Changes are saved locally."
                       FontSize="14"
                       TextColor="#856404" />
            </HorizontalStackLayout>
        </Frame>

        <!-- Task list -->
        <CollectionView Grid.Row="1"
                        ItemsSource="{Binding Tasks}"
                        SelectionMode="None">
            <CollectionView.ItemTemplate>
                <DataTemplate x:DataType="models:TaskItem">
                    <SwipeView>
                        <SwipeView.RightItems>
                            <SwipeItems>
                                <SwipeItem Text="Delete"
                                           BackgroundColor="Red"
                                           Command="{Binding Source={RelativeSource
                                               AncestorType={x:Type vm:TaskListViewModel}},
                                               Path=DeleteTaskCommand}"
                                           CommandParameter="{Binding .}" />
                            </SwipeItems>
                        </SwipeView.RightItems>

                        <Grid Padding="16,12" ColumnDefinitions="Auto,*,Auto">
                            <CheckBox IsChecked="{Binding IsCompleted}"
                                      VerticalOptions="Center" />
                            <VerticalStackLayout Grid.Column="1" Spacing="4">
                                <Label Text="{Binding Title}" FontSize="16" />
                                <Label Text="{Binding Description}"
                                       FontSize="12"
                                       TextColor="Gray" />
                            </VerticalStackLayout>

                            <!-- Sync indicator per item -->
                            <Label Grid.Column="2"
                                   Text="&#x25CF;"
                                   TextColor="{Binding IsDirty,
                                       Converter={StaticResource DirtyToColorConverter}}"
                                   FontSize="10"
                                   VerticalOptions="Center"
                                   ToolTipProperties.Text="{Binding IsDirty,
                                       Converter={StaticResource DirtyToTooltipConverter}}"
                                   />
                        </Grid>
                    </SwipeView>
                </DataTemplate>
            </CollectionView.ItemTemplate>
        </CollectionView>

        <!-- Sync status bar -->
        <Grid Grid.Row="2"
              BackgroundColor="#F8F9FA"
              Padding="12,8"
              ColumnDefinitions="*,Auto,Auto">
            <Label Text="{Binding SyncStatus}"
                   FontSize="12"
                   TextColor="Gray"
                   VerticalOptions="Center" />
            <Label Grid.Column="1"
                   Text="{Binding PendingChanges,
                       StringFormat='{0} pending'}"
                   FontSize="12"
                   TextColor="Gray"
                   VerticalOptions="Center"
                   Margin="0,0,12,0" />
            <Button Grid.Column="2"
                    Text="Sync"
                    Command="{Binding SyncCommand}"
                    IsEnabled="{Binding IsSyncing,
                        Converter={StaticResource InverseBoolConverter}}"
                    FontSize="12"
                    Padding="16,4" />
        </Grid>

    </Grid>

</ContentPage>

The small colored dot next to each item (green for synced, orange for pending) gives users immediate, glanceable feedback about which items have been synchronized. It's a pattern used by apps like Notion and Obsidian, and users tend to understand it intuitively without any explanation needed.

Automated Background Sync

You don't want users having to manually tap "Sync" every time. That gets old fast. A background sync timer handles periodic synchronization automatically:

public class BackgroundSyncService : IDisposable
{
    private readonly SyncEngine _syncEngine;
    private readonly ConnectivityService _connectivity;
    private Timer? _syncTimer;
    private readonly TimeSpan _syncInterval = TimeSpan.FromMinutes(5);
    private readonly TimeSpan _meteredSyncInterval = TimeSpan.FromMinutes(15);

    public BackgroundSyncService(
        SyncEngine syncEngine,
        ConnectivityService connectivity)
    {
        _syncEngine = syncEngine;
        _connectivity = connectivity;
    }

    public void Start()
    {
        var interval = _connectivity.IsMeteredConnection()
            ? _meteredSyncInterval
            : _syncInterval;

        _syncTimer = new Timer(
            async _ => await SyncIfNeededAsync(),
            null,
            TimeSpan.FromSeconds(30), // Initial delay
            interval);

        _connectivity.ConnectivityChanged += OnConnectivityChanged;
    }

    private async Task SyncIfNeededAsync()
    {
        if (!_connectivity.IsOnline) return;

        try
        {
            await _syncEngine.SynchronizeAsync();
        }
        catch (Exception ex)
        {
            System.Diagnostics.Debug.WriteLine(
                $"Background sync failed: {ex.Message}");
        }
    }

    private async void OnConnectivityChanged(object? sender, bool isOnline)
    {
        if (isOnline)
        {
            // Immediately sync when coming back online
            await SyncIfNeededAsync();
        }
    }

    public void Dispose()
    {
        _syncTimer?.Dispose();
        _connectivity.ConnectivityChanged -= OnConnectivityChanged;
    }
}

Notice the different sync intervals for metered vs. unmetered connections. On Wi-Fi, we sync every 5 minutes. On cellular, we stretch that to 15 minutes. Your users' data plans will thank you.

Using the Datasync Community Toolkit

If building the entire sync engine from scratch feels like overkill for your project (and honestly, for many apps it might be), the Datasync Community Toolkit — the modern replacement for Azure Mobile Apps' DataSync — provides a battle-tested implementation. It works with EF Core and supports .NET 8 and later.

// Install: CommunityToolkit.Datasync.Client

public class AppDbContext : OfflineDbContext
{
    public DbSet<TodoItem> TodoItems => Set<TodoItem>();

    protected override void OnDatasyncInitialization(
        DatasyncOfflineOptionsBuilder optionsBuilder)
    {
        var httpClient = new HttpClient
        {
            BaseAddress = new Uri("https://myapi.azurewebsites.net")
        };

        optionsBuilder.UseHttpClientOptions(
            options => options.HttpClient = httpClient);
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<TodoItem>(entity =>
        {
            entity.ToTable("TodoItems");
        });
    }
}

// Synchronization becomes a two-liner:
public async Task SyncAsync()
{
    await _dbContext.PushAsync();
    await _dbContext.PullAsync();
}

The toolkit handles optimistic concurrency, version tracking, and basic conflict resolution out of the box. It's particularly handy if your backend is already an ASP.NET Core API — the server-side toolkit integrates directly with EF Core to expose datasync-compatible endpoints with minimal configuration.

Testing Your Offline-First Architecture

Offline-first apps need specific testing strategies that go beyond the usual "run the test suite and ship it" approach. You can't just run everything with a network connection and assume it'll work when the network drops. Here are the critical scenarios you need to cover:

[TestClass]
public class SyncEngineTests
{
    private LocalDatabase _db = null!;
    private OperationQueue _queue = null!;
    private MockApiClient _apiClient = null!;
    private SyncEngine _syncEngine = null!;

    [TestInitialize]
    public async Task Setup()
    {
        _db = new LocalDatabase(":memory:");
        _queue = new OperationQueue(_db);
        _apiClient = new MockApiClient();
        var connectivity = new MockConnectivityService(isOnline: true);
        _syncEngine = new SyncEngine(
            _queue, _apiClient, _db, connectivity);
    }

    [TestMethod]
    public async Task PushChanges_WhenOnline_SendsAllPendingOperations()
    {
        // Arrange
        await _queue.EnqueueAsync(
            "TaskItem", 1, null, "CREATE", new TaskItem { Title = "Test" });
        await _queue.EnqueueAsync(
            "TaskItem", 2, null, "CREATE", new TaskItem { Title = "Test 2" });

        // Act
        var result = await _syncEngine.SynchronizeAsync();

        // Assert
        Assert.IsTrue(result.IsSuccess);
        Assert.AreEqual(2, result.PushedCount);
        Assert.AreEqual(0, await _queue.GetPendingCountAsync());
    }

    [TestMethod]
    public async Task Sync_WhenOffline_ReturnsOfflineResult()
    {
        // Arrange
        var offlineConnectivity =
            new MockConnectivityService(isOnline: false);
        var offlineEngine = new SyncEngine(
            _queue, _apiClient, _db, offlineConnectivity);

        // Act
        var result = await offlineEngine.SynchronizeAsync();

        // Assert
        Assert.IsFalse(result.IsSuccess);
        Assert.AreEqual("Device is offline", result.ErrorMessage);
    }

    [TestMethod]
    public async Task ConflictResolution_LastWriteWins_KeepsNewerVersion()
    {
        // Arrange
        var localItem = new TaskItem
        {
            Id = 1, RemoteId = "abc",
            Title = "Local edit",
            UpdatedAt = DateTime.UtcNow
        };

        var olderRemoteItem = new TaskItem
        {
            RemoteId = "abc",
            Title = "Remote edit",
            UpdatedAt = DateTime.UtcNow.AddMinutes(-5)
        };

        var resolver = new LastWriteWinsResolver();

        // Act
        var resolution = await resolver.ResolveAsync(
            localItem, olderRemoteItem);

        // Assert
        Assert.AreEqual(ConflictResolution.KeepLocal, resolution);
    }
}

Beyond unit tests, make sure to run through these real-world scenarios manually (or better yet, with integration tests):

  • Airplane mode toggle: Create items offline, toggle airplane mode off, verify sync completes without issues.
  • Slow network simulation: Use network link conditioner tools to simulate 2G speeds during sync. You'd be surprised how differently things behave.
  • Mid-sync disconnection: Start a large sync, kill the network midway through, and verify that partial progress is preserved.
  • Concurrent edits: Modify the same record on two devices while both are offline, bring both online, and verify your conflict resolution actually works as intended.
  • App kill during sync: Force-close the app mid-sync and verify data integrity on restart.
  • Database migration: Verify that schema changes don't destroy any pending sync operations.

Performance Considerations and Best Practices

Here are a few hard-won lessons from building production offline-first apps (some of these I learned the painful way):

  • Enable WAL mode: Always enable Write-Ahead Logging on SQLite. The performance difference for concurrent read/write operations is dramatic — especially when background sync is running while the user is actively interacting with the UI.
  • Index your sync columns: Add database indices on IsDirty, RemoteId, and IsDeleted. The sync engine queries these columns constantly, and without proper indices, performance degrades linearly as your data grows.
  • Batch operations: When pulling large numbers of remote changes, wrap them in a transaction. Individual INSERTs in SQLite are slow because each one triggers a filesystem sync. A single transaction wrapping 1,000 inserts can be roughly 100x faster than 1,000 individual inserts. Seriously.
  • Compact the operation queue: If a user creates an item and then updates it three times while offline, you end up with four operations in the queue. Compact these into a single CREATE with the latest data — the server doesn't need to see every intermediate state.
  • Mind your database file size: On mobile, storage matters. Periodically run VACUUM on your SQLite database after large deletions to reclaim disk space. Schedule this during idle periods, not during active use.
  • Use connection pooling wisely: SQLite connections are lightweight, but on mobile you should still keep active connections to a minimum. One connection for reads and one for writes (using WAL) tends to be the sweet spot for most apps.

Production Checklist

Before shipping your offline-first .NET MAUI app, walk through this checklist:

  1. Database encryption: Use SQLCipher or a similar library to encrypt the local database. An unencrypted SQLite file on a rooted or jailbroken device exposes all your user's data in plain text.
  2. Sync error surface: Make permanently failed operations visible to the user. Don't silently discard their data — that's a trust-killer.
  3. Data migration strategy: Plan for schema changes ahead of time. Will you use EF Core migrations or manual ALTER TABLE statements? Either way, test migrations against existing local data.
  4. Conflict resolution UX: Decide which entity types get automated resolution and which ones need user input. Document these decisions so future you (or your teammates) don't have to guess.
  5. Metered connection behavior: Respect the user's data plan. Large sync operations should prefer Wi-Fi whenever possible.
  6. Battery impact: Frequent background sync drains batteries. Consider using adaptive intervals based on recent user activity.
  7. Telemetry: Track sync success rates, conflict frequency, and average sync duration in production. These metrics will tell you whether your offline strategy is actually working in the real world.
  8. Maximum offline duration: What happens if a user goes offline for a month? Can your sync engine handle the resulting volume of changes? Set expectations and test the extremes.

Wrapping Up

Offline-first isn't just a technical architecture — it's a mindset shift. Instead of asking "how do I handle the case when there's no network?", you start asking "how do I take advantage of the network when it's available?" The result is an app that feels fast, reliable, and genuinely respectful of the user's context.

.NET MAUI gives you all the building blocks: SQLite for local storage, IConnectivity for network awareness, dependency injection for clean architecture, and the .NET ecosystem's rich collection of serialization, HTTP, and testing libraries. And if you want to skip some of the heavy lifting, the Datasync Community Toolkit can accelerate things significantly when your backend is ASP.NET Core.

The patterns we've walked through — the repository pattern, operation queue, push-then-pull sync engine, and multiple conflict resolution strategies — form a solid, production-ready foundation. Adapt them to your specific domain, test aggressively for the edge cases that will absolutely show up in production, and you'll ship a mobile app that your users can trust with their data. Whether they're on a fiber connection or stuck in an underground parking garage.

About the Author Editorial Team

Our team of expert writers and editors.