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
- What Is a State Snapshot?
- Why Snapshot-Based Recovery?
- What Should Be Included in a Snapshot?
- What Should Not Be Included?
- Designing the Snapshot Contract
- Creating a Snapshot Store
- Atomic Snapshot Persistence
- Building the Snapshot Manager
- Capturing Application State
- Restoring Application State
- Snapshot Versioning
- Expiration Policies
- Validation and Corruption Handling
- Lifecycle Integration
- Navigation Recovery
- Recovering Multi-Step Workflows
- Snapshot Frequency
- Security Considerations
- Testing Recovery
- Production Architecture
- Common Mistakes
- Best Practices
- Conclusion
- π§ 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.
- π‘οΈ 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.
- π 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?
- π¦ 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.
- π« 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.
- π§© 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.
- πΎ 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.
- π 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.
- β οΈ 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.
- βοΈ 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.
- ποΈ 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.
- πΈ 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.
- π 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.
- 𧬠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
- β¬οΈ 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.
- β³ 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.
- β 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.
- π₯ 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
- π‘οΈ 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.
- π± 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.
- πΈ 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.
- β±οΈ 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.
- π§ 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.
- π 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.
- π§Ή 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.
- π 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.
- π 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.
- π 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.
- π 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.
- π 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.
- π§ͺ 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);
}
- π₯ 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.
- π§ͺ 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.
- β‘ 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.
- ποΈ 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.
- π§Ή 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.
- π« 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.
- π« 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.
- π« 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
- ποΈ 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.
- π 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.
- π Best Practices =====================
For snapshot-based recovery in .NET MAUI:
- πΈ Capture only the state required for recovery.
- π§© Use explicit snapshot DTOs.
- πͺͺ Store stable entity identifiers instead of entire domain graphs.
- βοΈ Protect the last valid snapshot during writes.
- π’ Version snapshot contracts.
- β³ Define expiration policies.
- β Validate snapshots before restoring them.
- π₯ Treat corruption as a recoverable condition.
- π± Don't rely exclusively on background lifecycle events.
- π― Capture snapshots at meaningful workflow checkpoints.
- β±οΈ Debounce high-frequency changes.
- π§ Store semantic navigation state where possible.
- ποΈ Keep durable business data in the database.
- π¬ Keep durable delivery intent in an Outbox or equivalent mechanism.
- π Don't confuse recovery with retry.
- π§Ή Clear snapshots when workflows complete.
- π Minimize sensitive persisted data.
- π Log metadata rather than snapshot payloads.
- π§ͺ Test process interruption and corrupted state.
- π 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?
Sign in to react. Guest comments are still welcome.




Comments (0)
No approved comments yet.