Building a Snapshot-Based State Recovery System in .NET MAUI

πŸ—‚οΈ Building a Snapshot-Based State Recovery System in .NET MAUI

Modern mobile applications maintain much more state than what is currently visible on the screen.

A user may be halfway through a multi-step workflow, editing a complex form, configuring an order, preparing an offline operation, or navigating through several pages when the operating system suddenly terminates the application.

When the user returns, what should happen?

A simplistic application starts over:

    Application terminated
            β”‚
            β–Ό
    Application restarted
            β”‚
            β–Ό
    Home Page

A resilient application can do something much better:

    Application terminated
            β”‚
            β–Ό
    Application restarted
            β”‚
            β–Ό
    Load latest valid snapshot
            β”‚
            β–Ό
    Restore application state
            β”‚
            β–Ό
    Resume previous workflow

This is where snapshot-based state recovery becomes useful. 🧠

Instead of attempting to persist every individual state mutation, the application periodically captures a consistent representationβ€”or snapshotβ€”of the state required to recover an interrupted workflow.

In this article, we'll build a reusable snapshot-based recovery architecture for .NET MAUI that supports versioning, expiration, atomic persistence, validation, corruption handling, lifecycle integration, and controlled restoration.

The goal is not simply to serialize a ViewModel.

The goal is to create a reliable recovery boundary between one application execution and the next. πŸš€


πŸ“Œ Table of Contents

  1. What Is a State Snapshot?
  2. Why Snapshot-Based Recovery?
  3. What Should Be Included in a Snapshot?
  4. What Should Not Be Included?
  5. Designing the Snapshot Contract
  6. Creating a Snapshot Store
  7. Atomic Snapshot Persistence
  8. Building the Snapshot Manager
  9. Capturing Application State
  10. Restoring Application State
  11. Snapshot Versioning
  12. Expiration Policies
  13. Validation and Corruption Handling
  14. Lifecycle Integration
  15. Navigation Recovery
  16. Recovering Multi-Step Workflows
  17. Snapshot Frequency
  18. Security Considerations
  19. Testing Recovery
  20. Production Architecture
  21. Common Mistakes
  22. Best Practices
  23. Conclusion

  1. 🧠 What Is a State Snapshot? ===============================

A snapshot is a representation of application state at a specific point in time. For example, imagine an order workflow:

    Create Order
        β”‚
        β”œβ”€β”€ Customer selected
        β”œβ”€β”€ 4 products added
        β”œβ”€β”€ Delivery address entered
        β”œβ”€β”€ Step 3 of 5
        └── Unsaved notes

A snapshot might capture:

    {
      "workflow": "CreateOrder",
      "step": 3,
      "customerId": "CUST-1024",
      "productIds": [
        "P100",
        "P205",
        "P312",
        "P450"
      ],
      "deliveryAddress": "123 Example Street",
      "notes": "Deliver after 4 PM"
    }

If the process disappears, that snapshot becomes a recovery point.

    Runtime State
         β”‚
         β–Ό
    Capture
         β”‚
         β–Ό
    Snapshot
         β”‚
         β–Ό
    Persistent Storage

Later:

    Persistent Storage
         β”‚
         β–Ό
    Load Snapshot
         β”‚
         β–Ό
    Validate
         β”‚
         β–Ό
    Restore
         β”‚
         β–Ό
    Runtime State

This resembles checkpointing systems used elsewhere in computing.

Instead of reconstructing everything from the beginning, the application resumes from a known point.


  1. πŸ›‘οΈ Why Snapshot-Based Recovery? ===================================

Mobile processes are not permanent.

An application may be interrupted because of:

  • πŸ“± OS process termination
  • 🧠 Memory pressure
  • πŸ”‹ Battery optimization
  • πŸ”„ Application updates
  • πŸ’₯ Unexpected crashes
  • πŸ’€ Long periods in the background
  • πŸ‘€ User explicitly closing the application
  • βš™οΈ Device restarts

The application cannot assume:

    Background β†’ Foreground

will always happen within the same process.

Sometimes the real lifecycle is:

    Running
       β”‚
       β–Ό
    Background
       β”‚
       β–Ό
    Process terminated
       β”‚
       β–Ό
    New process
       β”‚
       β–Ό
    Application startup

Anything stored only in memory disappears. Consider:

    public sealed class CheckoutViewModel
    {
        public Customer? Customer { get; set; }
    
        public List<CartItem> Items { get; } = [];
    
        public int CurrentStep { get; set; }
    }

Once the process dies, this object no longer exists.

Snapshot recovery allows the application to reconstruct the meaningful parts of that state.


  1. πŸ†š Snapshot Recovery vs Normal Persistence =============================================

Snapshot recovery should not replace your primary database.

These systems solve different problems.

Persistence Type Purpose
Database Durable business data
Preferences Small configuration values
SecureStorage Sensitive small values
Cache Reusable temporary data
Outbox Durable synchronization intent
Snapshot Recover interrupted application state

For example:

    Order saved to database

is durable business data.

But:

    User is editing order
    Step = 3
    Selected tab = Shipping
    Unsaved notes = "..."

is recovery state.

That distinction is important.

A snapshot should usually answer:

What state do I need to reconstruct so the user can continue?

not:

How can I duplicate my entire database?


  1. πŸ“¦ What Should Be Included in a Snapshot? ============================================

A useful snapshot contains the minimum recoverable state. Good candidates include:

    Current workflow
    Current workflow step
    Entity identifiers
    Draft identifiers
    Unsaved form values
    Filters
    Search criteria
    Navigation destination
    Selected items
    Scroll position when valuable
    Temporary workflow options

For example:

    public sealed record CheckoutSnapshotState
    {
        public required Guid DraftOrderId { get; init; }
    
        public required int CurrentStep { get; init; }
    
        public Guid? CustomerId { get; init; }
    
        public IReadOnlyList<Guid> ProductIds { get; init; }
            = [];
    
        public string? Notes { get; init; }
    }

Notice that the snapshot stores identifiers rather than entire domain graphs.

Instead of:

    Customer object
      β”œβ”€β”€ Addresses
      β”œβ”€β”€ Orders
      β”œβ”€β”€ Preferences
      └── Hundreds of properties

prefer:

    CustomerId

and reconstruct current domain data from the appropriate repository.

This reduces snapshot size and avoids persisting stale domain objects.


  1. 🚫 What Should Not Be Included? ==================================

Avoid treating snapshots as memory dumps.

Usually don't persist:

    HttpClient instances
    Services
    Repositories
    Database connections
    CancellationTokenSource
    Commands
    Event handlers
    Streams
    Navigation objects
    Platform handles
    DI services
    Large image buffers

These are runtime infrastructure, not recoverable application state.

Also be careful with:

    Access tokens
    Passwords
    Secrets
    Payment information
    Sensitive personal data

A snapshot may be written to persistent storage, so its security characteristics must be understood. The basic rule is:

Persist data required to reconstruct behavior, not the runtime objects implementing that behavior.


  1. 🧩 Designing the Snapshot Contract =====================================

Let's define a generic snapshot envelope.

    public sealed record ApplicationSnapshot<TState>
    {
        public required Guid SnapshotId { get; init; }
    
        public required int SchemaVersion { get; init; }
    
        public required DateTimeOffset CreatedAtUtc { get; init; }
    
        public required string SnapshotType { get; init; }
    
        public required TState State { get; init; }
    }

This separates snapshot metadata from application state.

Example:

    var snapshot =
        new ApplicationSnapshot<CheckoutSnapshotState>
        {
            SnapshotId = Guid.NewGuid(),
            SchemaVersion = 1,
            CreatedAtUtc = DateTimeOffset.UtcNow,
            SnapshotType = "checkout",
            State = checkoutState
        };

Serialized:

    {
      "snapshotId": "f8cb5c7c-9c19-4b71-a81c-f46d9b2e1e2c",
      "schemaVersion": 1,
      "createdAtUtc": "2026-09-30T15:30:00Z",
      "snapshotType": "checkout",
      "state": {
        "draftOrderId": "846d83ae-c3dd-445a-bcc2-f143548532f4",
        "currentStep": 3,
        "customerId": "76807dc7-28bd-4a5e-b3ca-c98928d82613",
        "productIds": [],
        "notes": "Deliver after 4 PM"
      }
    }

The metadata gives the recovery system enough information to decide whether the snapshot is usable.


  1. πŸ’Ύ Creating a Snapshot Store ===============================

Persistence should be abstracted from recovery logic.

    public interface ISnapshotStore
    {
        Task SaveAsync<TState>(
            ApplicationSnapshot<TState> snapshot,
            CancellationToken cancellationToken = default);
    
        Task<ApplicationSnapshot<TState>?> LoadAsync<TState>(
            string snapshotType,
            CancellationToken cancellationToken = default);
    
        Task DeleteAsync(
            string snapshotType,
            CancellationToken cancellationToken = default);
    }

This allows different storage implementations:

    ISnapshotStore
         β”‚
         β”œβ”€β”€ FileSnapshotStore
         β”œβ”€β”€ SQLiteSnapshotStore
         └── EncryptedSnapshotStore

For a lightweight implementation, JSON files work well.


  1. πŸ“„ File-Based Snapshot Storage =================================

A simple implementation can serialize snapshots using System.Text.Json.

    public sealed class FileSnapshotStore : ISnapshotStore
    {
        private readonly string _directory;
    
        private readonly JsonSerializerOptions _jsonOptions =
            new(JsonSerializerDefaults.Web)
            {
                WriteIndented = false
            };
    
        public FileSnapshotStore()
        {
            _directory = Path.Combine(
                FileSystem.AppDataDirectory,
                "snapshots");
    
            Directory.CreateDirectory(_directory);
        }
    
        public async Task SaveAsync<TState>(
            ApplicationSnapshot<TState> snapshot,
            CancellationToken cancellationToken = default)
        {
            var path = GetPath(snapshot.SnapshotType);
    
            await using var stream = File.Create(path);
    
            await JsonSerializer.SerializeAsync(
                stream,
                snapshot,
                _jsonOptions,
                cancellationToken);
        }
    
        public async Task<ApplicationSnapshot<TState>?> LoadAsync<TState>(
            string snapshotType,
            CancellationToken cancellationToken = default)
        {
            var path = GetPath(snapshotType);
    
            if (!File.Exists(path))
                return null;
    
            await using var stream = File.OpenRead(path);
    
            return await JsonSerializer.DeserializeAsync<
                ApplicationSnapshot<TState>>(
                    stream,
                    _jsonOptions,
                    cancellationToken);
        }
    
        public Task DeleteAsync(
            string snapshotType,
            CancellationToken cancellationToken = default)
        {
            var path = GetPath(snapshotType);
    
            if (File.Exists(path))
                File.Delete(path);
    
            return Task.CompletedTask;
        }
    
        private string GetPath(string snapshotType)
        {
            var safeName =
                string.Concat(
                    snapshotType.Where(
                        c => char.IsLetterOrDigit(c) ||
                             c is '-' or '_'));
    
            return Path.Combine(
                _directory,
                $"{safeName}.snapshot.json");
        }
    }

This works, but there is an important reliability problem.


  1. ⚠️ The Partial Write Problem ===============================

Imagine the application writes directly to:

    checkout.snapshot.json

Then the process terminates halfway through the write.

The file could contain:

    {
      "snapshotId": "f8cb...",
      "schemaVersion":

The previous valid snapshot has now been destroyed.

The recovery system has nothing usable.

For state recovery, this is exactly the failure mode we're trying to protect against.


  1. βš›οΈ Atomic Snapshot Persistence ==================================

A safer approach is:

    Existing Snapshot
           β”‚
           β–Ό
    Write temporary snapshot
           β”‚
           β–Ό
    Flush / close
           β”‚
           β–Ό
    Replace committed snapshot

Conceptually:

    snapshot.json        ← last known good snapshot
    
    snapshot.tmp
         β”‚
         β”‚ write new state
         β–Ό
    complete
         β”‚
         β–Ό
    replace
         β”‚
         β–Ό
    snapshot.json        ← new known good snapshot

If writing the temporary file fails, the previous snapshot remains intact.

An implementation might look like:

    private async Task WriteAtomicallyAsync<T>(
        string path,
        T value,
        CancellationToken cancellationToken)
    {
        var temporaryPath = $"{path}.tmp";
    
        await using (var stream = new FileStream(
            temporaryPath,
            FileMode.Create,
            FileAccess.Write,
            FileShare.None))
        {
            await JsonSerializer.SerializeAsync(
                stream,
                value,
                _jsonOptions,
                cancellationToken);
    
            await stream.FlushAsync(cancellationToken);
        }
    
        File.Move(
            temporaryPath,
            path,
            overwrite: true);
    }

The exact durability guarantees depend on the filesystem and platform, so this should not be interpreted as a distributed transaction.

But the temporary-write-and-replace strategy substantially reduces the chance that an interrupted write destroys the previous usable snapshot.


  1. πŸŽ›οΈ Building the Snapshot Manager =====================================

The application should not interact with the storage implementation directly. Introduce:

    public interface ISnapshotManager
    {
        Task SaveAsync<TState>(
            string snapshotType,
            TState state,
            int schemaVersion,
            CancellationToken cancellationToken = default);
    
        Task<TState?> RestoreAsync<TState>(
            string snapshotType,
            int expectedSchemaVersion,
            CancellationToken cancellationToken = default);
    
        Task ClearAsync(
            string snapshotType,
            CancellationToken cancellationToken = default);
    }

Implementation:

    public sealed class SnapshotManager : ISnapshotManager
    {
        private readonly ISnapshotStore _store;
        private readonly ILogger<SnapshotManager> _logger;
    
        public SnapshotManager(
            ISnapshotStore store,
            ILogger<SnapshotManager> logger)
        {
            _store = store;
            _logger = logger;
        }
    
        public async Task SaveAsync<TState>(
            string snapshotType,
            TState state,
            int schemaVersion,
            CancellationToken cancellationToken = default)
        {
            var snapshot =
                new ApplicationSnapshot<TState>
                {
                    SnapshotId = Guid.NewGuid(),
                    SnapshotType = snapshotType,
                    SchemaVersion = schemaVersion,
                    CreatedAtUtc = DateTimeOffset.UtcNow,
                    State = state
                };
    
            await _store.SaveAsync(
                snapshot,
                cancellationToken);
    
            _logger.LogInformation(
                "Saved snapshot {SnapshotType} with ID {SnapshotId}",
                snapshotType,
                snapshot.SnapshotId);
        }
    
        public async Task<TState?> RestoreAsync<TState>(
            string snapshotType,
            int expectedSchemaVersion,
            CancellationToken cancellationToken = default)
        {
            var snapshot =
                await _store.LoadAsync<TState>(
                    snapshotType,
                    cancellationToken);
    
            if (snapshot is null)
                return default;
    
            if (snapshot.SchemaVersion != expectedSchemaVersion)
            {
                _logger.LogWarning(
                    "Snapshot {SnapshotType} has unsupported schema version {Version}",
                    snapshotType,
                    snapshot.SchemaVersion);
    
                return default;
            }
    
            return snapshot.State;
        }
    
        public Task ClearAsync(
            string snapshotType,
            CancellationToken cancellationToken = default)
        {
            return _store.DeleteAsync(
                snapshotType,
                cancellationToken);
        }
    }

Now ViewModels and workflows depend on recovery semantics rather than filesystem details.


  1. πŸ“Έ Capturing State ======================

We don't want the snapshot manager to know how every feature in the application works. Each recoverable workflow can expose its own state.

    public interface ISnapshotStateProvider<TState>
    {
        TState CaptureState();
    
        Task RestoreStateAsync(
            TState state,
            CancellationToken cancellationToken = default);
    }

For example:

    public sealed class CheckoutViewModel :
        ISnapshotStateProvider<CheckoutSnapshotState>
    {
        public Guid DraftOrderId { get; set; }
    
        public Guid? CustomerId { get; set; }
    
        public ObservableCollection<Guid> ProductIds { get; }
            = [];
    
        public int CurrentStep { get; set; }
    
        public string? Notes { get; set; }
    
        public CheckoutSnapshotState CaptureState()
        {
            return new CheckoutSnapshotState
            {
                DraftOrderId = DraftOrderId,
                CustomerId = CustomerId,
                ProductIds = ProductIds.ToArray(),
                CurrentStep = CurrentStep,
                Notes = Notes
            };
        }
    
        public Task RestoreStateAsync(
            CheckoutSnapshotState state,
            CancellationToken cancellationToken = default)
        {
            DraftOrderId = state.DraftOrderId;
            CustomerId = state.CustomerId;
            CurrentStep = state.CurrentStep;
            Notes = state.Notes;
    
            ProductIds.Clear();
    
            foreach (var productId in state.ProductIds)
                ProductIds.Add(productId);
    
            return Task.CompletedTask;
        }
    }

This keeps the snapshot DTO separate from the ViewModel itself.

That's valuable because ViewModels often contain things that should never be serialized.


  1. πŸ”„ Restoring State ======================

Recovery should be deliberate. For example:

    private async Task RestoreCheckoutAsync(
        CancellationToken cancellationToken)
    {
        var state =
            await snapshotManager.RestoreAsync<CheckoutSnapshotState>(
                "checkout",
                expectedSchemaVersion: 1,
                cancellationToken);
    
        if (state is null)
            return;
    
        await checkoutViewModel.RestoreStateAsync(
            state,
            cancellationToken);
    }

The sequence matters. Prefer:

    Application startup
          β”‚
          β–Ό
    Initialize infrastructure
          β”‚
          β–Ό
    Initialize database
          β”‚
          β–Ό
    Initialize authentication
          β”‚
          β–Ό
    Load snapshot
          β”‚
          β–Ό
    Validate snapshot
          β”‚
          β–Ό
    Rehydrate domain dependencies
          β”‚
          β–Ό
    Restore workflow
          β”‚
          β–Ό
    Navigate

Don't navigate first and attempt to reconstruct state afterward.

That often creates visible UI flicker and inconsistent ViewModels.


  1. 🧬 Snapshot Versioning ==========================

Snapshots are serialized contracts. Those contracts will evolve. Version 1 might contain:

    {
      "currentStep": 2,
      "customerId": "..."
    }

Version 2 might require:

    {
      "currentStep": 2,
      "customerId": "...",
      "shippingMethod": "Express"
    }

Without versioning, the application may deserialize old snapshots into assumptions that are no longer valid.

That's why the envelope contains:

    public required int SchemaVersion { get; init; }

Recovery can then make an explicit decision:

    Snapshot version = Current
            β”‚
            └── Restore
    
    Snapshot version = Older but supported
            β”‚
            └── Upgrade snapshot
    
    Snapshot version = Unsupported
            β”‚
            └── Discard / fallback safely

  1. ⬆️ Snapshot Migration =========================

For important workflows, simply discarding old snapshots after every application update may not be acceptable. You can introduce snapshot migrations.

    public interface ISnapshotMigration
    {
        string SnapshotType { get; }
    
        int FromVersion { get; }
    
        int ToVersion { get; }
    
        JsonDocument Migrate(JsonDocument snapshot);
    }

Then:

    Snapshot v1
         β”‚
         β–Ό
    Migration 1 β†’ 2
         β”‚
         β–Ό
    Snapshot v2
         β”‚
         β–Ό
    Restore

This concept is similar to database migrations, but should generally remain much lighter.

For many applications, the policy can simply be:

    Compatible snapshot β†’ restore
    Incompatible snapshot β†’ discard

The complexity of snapshot migration is justified only when preserving interrupted workflows across application upgrades is valuable enough.


  1. ⏳ Snapshot Expiration =========================

Not every snapshot should survive forever. Imagine the user starts checkout:

    September 1

and returns:

    December 18

Should the application resume that exact checkout?

Probably not automatically.

Snapshots can have a lifetime.

    public sealed record SnapshotPolicy
    {
        public required TimeSpan MaximumAge { get; init; }
    }

Validation:

    var age =
        DateTimeOffset.UtcNow -
        snapshot.CreatedAtUtc;
    
    if (age > policy.MaximumAge)
    {
        await _store.DeleteAsync(
            snapshot.SnapshotType,
            cancellationToken);
    
        return default;
    }

Different workflows can use different expiration policies.

Snapshot Example Lifetime
Search state 30 minutes
Checkout 24 hours
Multi-step form 7 days
Draft workflow 30 days
Temporary onboarding 24 hours

These are product decisions rather than universal values.


  1. βœ… Snapshot Validation =========================

A successfully deserialized snapshot is not necessarily valid. Suppose:

    CustomerId = 42

but customer 42 was deleted during synchronization. Or:

    CurrentStep = 7

while the workflow now has only five steps. Recovery therefore needs semantic validation.

    public interface ISnapshotValidator<TState>
    {
        Task<bool> IsValidAsync(
            TState state,
            CancellationToken cancellationToken = default);
    }

Example:

    public sealed class CheckoutSnapshotValidator
        : ISnapshotValidator<CheckoutSnapshotState>
    {
        private readonly ICustomerRepository _customers;
    
        public CheckoutSnapshotValidator(
            ICustomerRepository customers)
        {
            _customers = customers;
        }
    
        public async Task<bool> IsValidAsync(
            CheckoutSnapshotState state,
            CancellationToken cancellationToken = default)
        {
            if (state.CurrentStep is < 1 or > 5)
                return false;
    
            if (state.CustomerId is null)
                return true;
    
            return await _customers.ExistsAsync(
                state.CustomerId.Value,
                cancellationToken);
        }
    }

This prevents invalid historical state from contaminating the current runtime.


  1. πŸ’₯ Corrupted Snapshots ==========================

Persistent files can become corrupted.

Deserialization should therefore be treated as an expected failure boundary.

    try
    {
        return await _store.LoadAsync<CheckoutSnapshotState>(
            "checkout",
            cancellationToken);
    }
    catch (JsonException ex)
    {
        logger.LogWarning(
            ex,
            "Checkout snapshot is corrupted.");
    
        await _store.DeleteAsync(
            "checkout",
            cancellationToken);
    
        return null;
    }

The important part is that snapshot corruption should normally not make the entire application unusable.

Snapshots are recovery aids.

If a snapshot cannot be trusted:

    Corrupted Snapshot
           β”‚
           β–Ό
    Reject
           β”‚
           β–Ό
    Clear
           β”‚
           β–Ό
    Normal startup

  1. πŸ›‘οΈ Add a Snapshot Integrity Check ======================================

For scenarios where accidental corruption detection matters, snapshots can include an integrity value. For example:

    Payload
       β”‚
       β–Ό
    SHA-256
       β”‚
       β–Ό
    Checksum

The persisted envelope could contain:

    {
      "schemaVersion": 2,
      "payload": "...",
      "checksum": "..."
    }

During recovery:

    Read payload
         β”‚
         β–Ό
    Calculate checksum
         β”‚
         β–Ό
    Compare
       β”Œβ”€β”΄β”€β”
       β”‚   β”‚
     Match Mismatch
       β”‚   β”‚
    Restore Reject

A plain checksum detects accidental corruption; it does not provide authenticity against a malicious actor. If tamper resistance is required, use an authenticated cryptographic design rather than treating a simple hash as a security mechanism.


  1. πŸ“± Lifecycle Integration ============================

.NET MAUI exposes lifecycle events that can be used as signals for state capture. Conceptually:

    Foreground
        β”‚
        β–Ό
    Application active
        β”‚
        β–Ό
    State changes
        β”‚
        β–Ό
    Application backgrounding
        β”‚
        β–Ό
    Capture snapshot

Lifecycle configuration can participate in triggering recovery behavior. However, there is an important limitation:

Do not rely exclusively on a final lifecycle callback to save critical state.

The operating system may terminate the process under conditions where your application does not receive enough time to perform expensive persistence.

A safer strategy is to save at meaningful checkpoints throughout the workflow.


  1. πŸ“Έ Checkpoint-Based Snapshots =================================

Instead of only saving when the app backgrounds:

    User changes important state
           β”‚
           β–Ό
    Checkpoint
           β”‚
           β–Ό
    Snapshot

For a checkout workflow:

    Customer selected
           β”‚
           β–Ό
    Snapshot
    
    Products changed
           β”‚
           β–Ό
    Snapshot
    
    Shipping confirmed
           β”‚
           β–Ό
    Snapshot
    
    Payment step entered
           β”‚
           β–Ό
    Snapshot

This makes the last valid snapshot much closer to the user's actual progress.

It also reduces dependency on unpredictable lifecycle behavior.


  1. ⏱️ Don't Snapshot Every Keystroke =====================================

The opposite extreme is also problematic. Imagine:

    User types:
    
    H
    He
    Hel
    Hell
    Hello

Saving five complete snapshots is unnecessary. For high-frequency changes, use debounce.

    private CancellationTokenSource? _snapshotCts;
    
    private async Task ScheduleSnapshotAsync()
    {
        _snapshotCts?.Cancel();
    
        _snapshotCts =
            new CancellationTokenSource();
    
        try
        {
            await Task.Delay(
                TimeSpan.FromMilliseconds(750),
                _snapshotCts.Token);
    
            await SaveSnapshotAsync(
                _snapshotCts.Token);
        }
        catch (OperationCanceledException)
        {
        }
    }

Now rapid state changes become:

    Change
    Change
    Change
    Change
          β”‚
          β–Ό
       750 ms
          β”‚
          β–Ό
    Snapshot

This reduces unnecessary I/O.


  1. 🧭 Navigation Recovery ==========================

Restoring data isn't enough if the user was halfway through a workflow. Suppose the snapshot says:

    Workflow = Checkout
    Step = 4

After state restoration, navigation can resume at:

    //checkout/payment

Instead of persisting raw internal route strings everywhere, consider storing semantic destinations.

    public enum RecoveryDestination
    {
        None,
        CheckoutCustomer,
        CheckoutProducts,
        CheckoutShipping,
        CheckoutPayment,
        CheckoutReview
    }

The application then maps:

    RecoveryDestination
            β”‚
            β–Ό
    Navigation Service
            β”‚
            β–Ό
    Current internal route

This decouples persisted snapshots from internal Shell route changes.


  1. πŸ”„ Recovering Multi-Step Workflows ======================================

Snapshot recovery is particularly useful for wizards. Imagine:

    Insurance Claim
    
    Step 1 ─ Personal Information
    Step 2 ─ Incident Details
    Step 3 ─ Photos
    Step 4 ─ Review
    Step 5 ─ Submit

The snapshot can capture:

    public sealed record ClaimSnapshotState
    {
        public required Guid DraftId { get; init; }
    
        public required int CurrentStep { get; init; }
    
        public string? IncidentDescription { get; init; }
    
        public IReadOnlyList<string> PhotoReferences { get; init; }
            = [];
    }

Notice:

    PhotoReferences

rather than raw image bytes.

Large assets should normally be persisted independently.

The snapshot references them.


  1. 🧹 Clearing Completed Snapshots ===================================

Snapshots must have a defined lifecycle. Once the workflow succeeds:

    Draft
      β”‚
      β–Ό
    Workflow
      β”‚
      β–Ό
    Submit
      β”‚
      β–Ό
    Server confirms success
      β”‚
      β–Ό
    Clear Snapshot

For example:

    await orderService.SubmitAsync(
        order,
        cancellationToken);
    
    await snapshotManager.ClearAsync(
        "checkout",
        cancellationToken);

Otherwise the application could restore a workflow that has already completed.

This is especially dangerous when the workflow performs side effects.


  1. πŸ” Recovery Is Not Retry ============================

Suppose an order submission was in progress when the process disappeared. A snapshot might say:

    Checkout step = Submit

That does not mean the application should automatically submit again.

Recovery and command delivery are different responsibilities.

    Snapshot
        β”‚
        └── Restores UI/workflow state
    
    Outbox
        β”‚
        └── Restores durable delivery intent
    
    Idempotent Command
        β”‚
        └── Prevents duplicate business effects

This distinction is critical.

A snapshot should not accidentally become an unreliable message queue.

For durable business operations, combine it with appropriate mechanisms such as an Outbox and idempotency.


  1. πŸ” Security Considerations ==============================

Snapshots may contain:

    Names
    Addresses
    Draft form content
    Entity identifiers
    Business information
    Search history
    Workflow context

Treat them according to the sensitivity of the data. Avoid:

    Access tokens
    Refresh tokens
    Passwords
    Private keys
    Raw payment credentials

For sensitive recoverable state, consider:

    Minimal snapshot content
           +
    Platform-appropriate protected storage
           +
    Encryption where appropriate
           +
    Short retention
           +
    Explicit deletion

Don't assume that putting JSON inside AppDataDirectory automatically makes all of its contents appropriate to store in plaintext.


  1. πŸ“Š Snapshot Metadata ========================

Additional metadata can improve diagnostics.

    public sealed record SnapshotMetadata
    {
        public required string AppVersion { get; init; }
    
        public required string Platform { get; init; }
    
        public required DateTimeOffset CreatedAtUtc { get; init; }
    
        public required string SnapshotType { get; init; }
    }

A diagnostic record could show:

    Snapshot ID:      f8cb5c7c...
    Type:             checkout
    Schema:           3
    Created:          2026-09-30 15:30 UTC
    App Version:      4.2.0
    Platform:         Android

This becomes useful when investigating recovery failures after application upgrades.


  1. πŸ“ Logging Recovery =======================

Useful events include:

    Snapshot captured
    Snapshot replaced
    Snapshot discovered
    Snapshot expired
    Snapshot rejected
    Snapshot migrated
    Snapshot restored
    Snapshot corrupted
    Snapshot cleared
    Recovery completed
    Recovery failed

For example:

    _logger.LogInformation(
        "Restored snapshot {SnapshotId} of type {SnapshotType}",
        snapshot.SnapshotId,
        snapshot.SnapshotType);

Avoid logging the full serialized snapshot.

A snapshot may contain user data.

Log metadata, not sensitive payloads.


  1. πŸ’‰ Dependency Injection ===========================

Register the infrastructure:

    builder.Services.AddSingleton<ISnapshotStore, FileSnapshotStore>();
    
    builder.Services.AddSingleton<ISnapshotManager, SnapshotManager>();
    
    builder.Services.AddSingleton<
        ISnapshotValidator<CheckoutSnapshotState>,
        CheckoutSnapshotValidator>();

Consumers depend on abstractions:

    ViewModel
       β”‚
       β–Ό
    ISnapshotManager
       β”‚
       β–Ό
    ISnapshotStore

not:

    ViewModel
       β”‚
       β–Ό
    File.ReadAllText(...)

This keeps persistence replaceable and testable.


  1. πŸ§ͺ Testing Snapshot Recovery ================================

Snapshot systems should be tested as recovery systems, not just serialization utilities. At minimum, verify:

    Save β†’ Load
    Save β†’ Replace β†’ Load latest
    Save β†’ Restart simulation β†’ Restore
    Expired snapshot β†’ Reject
    Wrong version β†’ Reject
    Corrupted JSON β†’ Reject
    Invalid domain reference β†’ Reject
    Completed workflow β†’ Snapshot removed
    Cancellation β†’ Safe behavior

For example:

    [Fact]
    public async Task RestoreAsync_ReturnsSavedState()
    {
        var state =
            new CheckoutSnapshotState
            {
                DraftOrderId = Guid.NewGuid(),
                CurrentStep = 3,
                Notes = "Test"
            };
    
        await manager.SaveAsync(
            "checkout",
            state,
            schemaVersion: 1);
    
        var restored =
            await manager.RestoreAsync<CheckoutSnapshotState>(
                "checkout",
                expectedSchemaVersion: 1);
    
        Assert.NotNull(restored);
        Assert.Equal(3, restored.CurrentStep);
        Assert.Equal("Test", restored.Notes);
    }

  1. πŸ’₯ Simulating Interrupted Writes ====================================

One particularly valuable test is:

    Existing valid snapshot
            β”‚
            β–Ό
    Start writing new snapshot
            β”‚
            β–Ό
    Simulate failure
            β”‚
            β–Ό
    Restart
            β”‚
            β–Ό
    Old snapshot remains recoverable

This verifies one of the most important properties of the design:

A failed checkpoint should not destroy the last known good recovery point.

Your storage abstraction makes this kind of failure injection much easier to test.


  1. πŸ§ͺ Test Application Upgrades ================================

Consider:

    App v1
    Snapshot schema 1
           β”‚
           β–Ό
    Upgrade
           β”‚
           β–Ό
    App v2
    Snapshot schema 2

Tests should define the expected behavior:

    Migrate?
    Reject?
    Discard?
    Restore partially?

Don't leave this behavior accidental.

Application upgrades are exactly when persisted runtime state is most likely to encounter compatibility problems.


  1. ⚑ Performance =================

Snapshot creation should be lightweight. If your snapshot is:

    150 KB

that's very different from:

    250 MB

Large snapshots usually indicate that too much state is being captured. Prefer:

    IDs
    Small DTOs
    Primitive values
    Workflow metadata
    References to durable assets

instead of:

    Images
    Large binary data
    Entire API responses
    Full database tables
    Huge object graphs

Snapshot recovery should be faster than rebuilding the entire workflow, not become another expensive startup subsystem.


  1. πŸ—ƒοΈ Multiple Snapshots ==========================

Some applications need more than one recoverable workflow. For example:

    snapshots/
    β”œβ”€β”€ checkout.snapshot.json
    β”œβ”€β”€ inspection.snapshot.json
    β”œβ”€β”€ report-draft.snapshot.json
    └── onboarding.snapshot.json

This can work well if workflows are independent.

For multiple instances of the same workflow, use stable identifiers.

    inspection-7f8a.snapshot.json
    inspection-a921.snapshot.json
    inspection-b410.snapshot.json

Now the application can maintain multiple recovery points without overwriting unrelated work.


  1. 🧹 Snapshot Retention =========================

Multiple snapshots require cleanup. A retention policy might define:

    Maximum age
    Maximum number of snapshots
    Maximum storage size
    Completed workflow cleanup
    Orphaned asset cleanup

For example:

    public sealed record SnapshotRetentionPolicy
    {
        public TimeSpan MaximumAge { get; init; }
            = TimeSpan.FromDays(7);
    
        public int MaximumSnapshots { get; init; }
            = 10;
    }

Cleanup can occur during controlled maintenance rather than on every state change.


  1. 🚫 Common Mistake: Serializing the Entire ViewModel =======================================================

This is tempting:

    var json =
        JsonSerializer.Serialize(viewModel);

But a ViewModel may contain:

    Commands
    Services
    Observable collections
    Event subscriptions
    Cancellation sources
    Navigation services
    Transient flags
    Computed properties
    Runtime-only state

Create an explicit snapshot DTO instead.

    ViewModel
       β”‚
       β–Ό
    CaptureState()
       β”‚
       β–Ό
    Snapshot DTO

That gives you control over the persistence contract.


  1. 🚫 Common Mistake: Using Snapshots as the Database ======================================================

If you find yourself persisting:

    All customers
    All products
    All orders
    All transactions
    All application data

inside a snapshot, you are probably rebuilding a database poorly.

Snapshots should complement durable persistence.

A healthy separation is:

    SQLite
      └── Durable domain data
    
    Snapshot Store
      └── Recoverable execution state
    
    Outbox
      └── Pending external operations
    
    Secure Storage
      └── Appropriate sensitive credentials

Each mechanism has a distinct responsibility.


  1. 🚫 Common Mistake: Blind Restoration ========================================

Never assume:

    Snapshot exists
          =
    Snapshot should be restored

Recovery should consider:

    Is it readable?
    Is it supported?
    Is it expired?
    Is it semantically valid?
    Is the user still authenticated?
    Does the referenced entity exist?
    Has the workflow already completed?
    Is restoration appropriate in the current session?

A better pipeline is:

    Discover
       β”‚
       β–Ό
    Deserialize
       β”‚
       β–Ό
    Version Check
       β”‚
       β–Ό
    Expiration Check
       β”‚
       β–Ό
    Semantic Validation
       β”‚
       β–Ό
    Recovery Decision
       β”‚
       β–Ό
    Restore

  1. πŸ—οΈ Production Architecture ===============================

A production-oriented architecture could look like:

    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
    β”‚                 .NET MAUI App                β”‚
    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                            β”‚
                            β–Ό
    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
    β”‚            Application Startup               β”‚
    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                            β”‚
                β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                β–Ό                       β–Ό
        Infrastructure Init       Database Init
                β”‚                       β”‚
                β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                            β–Ό
    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
    β”‚             Recovery Coordinator             β”‚
    β”‚                                              β”‚
    β”‚  β€’ Discover snapshot                         β”‚
    β”‚  β€’ Validate version                          β”‚
    β”‚  β€’ Check expiration                          β”‚
    β”‚  β€’ Validate domain references                β”‚
    β”‚  β€’ Determine recovery destination            β”‚
    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                            β”‚
                            β–Ό
    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
    β”‚               Snapshot Manager               β”‚
    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                            β”‚
                            β–Ό
    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
    β”‚                Snapshot Store                β”‚
    β”‚                                              β”‚
    β”‚  File / SQLite / Protected Storage           β”‚
    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                            β”‚
                            β–Ό
                     Persistent Snapshot
                            β”‚
                            β–Ό
    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
    β”‚             Workflow Restoration             β”‚
    β”‚                                              β”‚
    β”‚  Rehydrate state β†’ Restore VM β†’ Navigate     β”‚
    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

During normal execution:

    User Action
        β”‚
        β–Ό
    State Mutation
        β”‚
        β–Ό
    Important Checkpoint?
        β”‚
     β”Œβ”€β”€β”΄β”€β”€β”
     No   Yes
     β”‚     β”‚
     β–Ό     β–Ό
    Continue Capture Snapshot
              β”‚
              β–Ό
          Atomic Save

This separates state capture, persistence, validation, and recovery orchestration.


  1. πŸ“‹ Recovery Strategy Comparison ===================================
Strategy Data Safety Complexity Recovery Quality
Memory only Low Low ❌
Save on background only Medium Low ⚠️
Persist entire ViewModel Medium Medium ⚠️
Explicit snapshot DTO High Medium βœ…
Snapshot + checkpoints High Medium βœ…
Snapshot + atomic storage + validation Very High Medium ⭐
Snapshot + versioning + recovery coordinator Very High Higher ⭐ Production

The best solution depends on how costly interrupted user work is.

A simple content browser may need almost nothing.

A 30-minute field inspection workflow may justify a sophisticated recovery architecture.


  1. πŸ† Best Practices =====================

For snapshot-based recovery in .NET MAUI:

  1. πŸ“Έ Capture only the state required for recovery.
  2. 🧩 Use explicit snapshot DTOs.
  3. πŸͺͺ Store stable entity identifiers instead of entire domain graphs.
  4. βš›οΈ Protect the last valid snapshot during writes.
  5. πŸ”’ Version snapshot contracts.
  6. ⏳ Define expiration policies.
  7. βœ… Validate snapshots before restoring them.
  8. πŸ’₯ Treat corruption as a recoverable condition.
  9. πŸ“± Don't rely exclusively on background lifecycle events.
  10. 🎯 Capture snapshots at meaningful workflow checkpoints.
  11. ⏱️ Debounce high-frequency changes.
  12. 🧭 Store semantic navigation state where possible.
  13. πŸ—ƒοΈ Keep durable business data in the database.
  14. πŸ“¬ Keep durable delivery intent in an Outbox or equivalent mechanism.
  15. πŸ” Don't confuse recovery with retry.
  16. 🧹 Clear snapshots when workflows complete.
  17. πŸ” Minimize sensitive persisted data.
  18. πŸ“ Log metadata rather than snapshot payloads.
  19. πŸ§ͺ Test process interruption and corrupted state.
  20. πŸš€ Test recovery across application upgrades.

🎯 Conclusion

Mobile applications operate in an environment where process lifetime is outside the application's control.

A user can spend twenty minutes completing a workflow and then lose the process because of memory pressure, an OS decision, a device restart, an application update, or an unexpected failure.

Without persistent recovery state:

    Process dies
        β”‚
        β–Ό
    Runtime state disappears
        β”‚
        β–Ό
    User starts again

With snapshot-based recovery:

    Runtime State
         β”‚
         β–Ό
    Checkpoint
         β”‚
         β–Ό
    Versioned Snapshot
         β”‚
         β–Ό
    Atomic Persistence
         β”‚
         β–Ό
    Process Ends
         β”‚
         β–Ό
    Application Restarts
         β”‚
         β–Ό
    Validate Snapshot
         β”‚
         β–Ό
    Restore Workflow

The important architectural shift is to stop treating application state as something that exists only while the process is alive.

Instead, identify the portions of transient state that are valuable enough to survive process boundaries and represent them through explicit, versioned recovery contracts.

A robust solution combines: Explicit State + Checkpoints + Atomic Persistence + Versioning + Validation + Controlled Restoration

The result is not merely persistence. It is continuity. πŸ—‚οΈπŸš€

And for workflows where users invest meaningful time entering data or progressing through multiple steps, that continuity can make the difference between an application that technically survives failure and one that actually provides a resilient user experience.


Was this useful?

Comments (0)

Leave a comment

Submit for moderation
An unhandled error has occurred. Reload πŸ—™