Implementing App Lifecycle State Persistence in .NET MAUI
π Implementing App Lifecycle State Persistence in .NET MAUI
Mobile applications rarely stay in the foreground forever.
A user may start filling out a form, navigate several pages deep into the application, switch to another app, lock the device, receive a phone call, or have the operating system terminate the application to reclaim resources. When the user returns, one question becomes very important:
Can the application restore enough state to continue where the user left off?
In .NET MAUI, handling application lifecycle events is relatively straightforward. The more interesting problem is deciding what state should survive, where it should be stored, and how it should be restored without coupling the application to platform-specific lifecycle APIs.
In this article, we'll build a small reusable lifecycle state persistence mechanism for .NET MAUI.
We'll cover:
- π± Understanding MAUI lifecycle transitions
- πΎ Persisting lightweight application state
- β»οΈ Restoring state after activation
- π§© Creating a reusable state service
- π Connecting it to MAUI lifecycle events
- π Choosing between Preferences and SecureStorage
- β οΈ Avoiding common lifecycle persistence mistakes
- π§ͺ Making the implementation testable
π± Why Lifecycle State Persistence Matters
Consider a simple application where the user is editing a profile. They have entered:
Name: John Doe
Email: john@example.com
Biography: .NET MAUI developer
Before pressing Save, they switch to another application.
Depending on the platform, memory pressure, and how long the application remains inactive, your process may eventually be terminated.
If that temporary state exists only inside the ViewModel:
public string Name { get; set; }
public string Email { get; set; }
public string Biography { get; set; }
it disappears when the process disappears. The application therefore needs to distinguish between different categories of state.
| State | Example | Persist? |
|---|---|---|
| Permanent application data | User profile | β Database/API |
| Authentication secrets | Access token | π Secure storage |
| User preferences | Theme | β Preferences |
| Temporary UI state | Current tab | β‘ Maybe |
| Draft state | Unsubmitted form | β Often useful |
| Derived state | Filtered collection | β Usually rebuild |
| Navigation state | Current route | β‘ Depends on UX |
The important point is that lifecycle persistence is not the same thing as database persistence.
You normally don't want to serialize your entire application every time it enters the background. Instead, persist the minimum state required to reconstruct the user's context.
π Understanding the .NET MAUI Lifecycle
A .NET MAUI application exposes lifecycle events that correspond to important transitions in the native application lifecycle. These can be configured through ConfigureLifecycleEvents. For example:
builder.ConfigureLifecycleEvents(events =>
{
#if ANDROID
events.AddAndroid(android =>
{
android.OnPause(activity =>
{
// Application is leaving the foreground.
});
android.OnResume(activity =>
{
// Application is returning to the foreground.
});
});
#endif
});
However, putting persistence logic directly inside platform callbacks quickly becomes difficult to maintain. For example, this is something we want to avoid:
android.OnPause(activity =>
{
Preferences.Set("CurrentPage", "Profile");
Preferences.Set("DraftName", viewModel.Name);
Preferences.Set("DraftEmail", viewModel.Email);
});
Now the lifecycle configuration knows about:
- UI state
- ViewModels
- persistence
- serialization
- application-specific behavior That creates unnecessary coupling. A better architecture is:
Platform Lifecycle
β
βΌ
Lifecycle Coordinator
β
βΌ
Application State Service
β
βΌ
Persistence Provider
The platform should simply tell the application:
"We're entering the background."
The application decides what that means.
π§© Creating an Application State Model
First, let's define the state we want to preserve.
public sealed class AppState
{
public string? CurrentRoute { get; set; }
public string? DraftText { get; set; }
public DateTime LastUpdatedUtc { get; set; }
}
Keep this object intentionally small. The goal isn't to reproduce every object currently living in memory. Instead, it should contain enough information to reconstruct meaningful state. For example:
AppState
β
βββ CurrentRoute
βββ DraftText
βββ LastUpdatedUtc
For larger applications, you might eventually split this into multiple state objects.
ApplicationState
βββ NavigationState
βββ DraftState
βββ SearchState
βββ SessionState
But for a small implementation, one model is enough.
ποΈ Creating the State Persistence Abstraction
Next, we'll create an interface.
public interface IAppStateService
{
Task SaveAsync(
AppState state,
CancellationToken cancellationToken = default);
Task<AppState?> RestoreAsync(
CancellationToken cancellationToken = default);
Task ClearAsync();
}
This abstraction gives us an important architectural benefit.
The rest of the application doesn't care whether state is stored using:
- Preferences
- JSON files
- SQLite
- SecureStorage
- platform storage
- a custom persistence mechanism
The lifecycle coordinator only knows about IAppStateService.
πΎ Implementing State Persistence
For lightweight non-sensitive state, Preferences is a convenient option.
Instead of creating many individual keys, we can serialize our state object.
using System.Text.Json;
public sealed class PreferencesAppStateService : IAppStateService
{
private const string StateKey = "application_state";
public Task SaveAsync(
AppState state,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
state.LastUpdatedUtc = DateTime.UtcNow;
var json = JsonSerializer.Serialize(state);
Preferences.Default.Set(StateKey, json);
return Task.CompletedTask;
}
public Task<AppState?> RestoreAsync(
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var json = Preferences.Default.Get<string?>(
StateKey,
null);
if (string.IsNullOrWhiteSpace(json))
return Task.FromResult<AppState?>(null);
var state = JsonSerializer.Deserialize<AppState>(json);
return Task.FromResult(state);
}
public Task ClearAsync()
{
Preferences.Default.Remove(StateKey);
return Task.CompletedTask;
}
}
Now our application state is isolated behind a service.
π Registering the Service
Register it in MauiProgram.cs.
builder.Services.AddSingleton<IAppStateService, PreferencesAppStateService>();
This gives us:
Lifecycle
β
IAppStateService
β
PreferencesAppStateService
β
Preferences
More importantly, consumers depend on the abstraction rather than the implementation.
π― Creating a Lifecycle Coordinator
Now let's introduce the component responsible for coordinating lifecycle transitions.
public interface IAppLifecycleCoordinator
{
Task EnterBackgroundAsync();
Task EnterForegroundAsync();
}
And its implementation:
public sealed class AppLifecycleCoordinator : IAppLifecycleCoordinator
{
private readonly IAppStateService _stateService;
public AppLifecycleCoordinator(
IAppStateService stateService)
{
_stateService = stateService;
}
public async Task EnterBackgroundAsync()
{
var state = new AppState
{
CurrentRoute = Shell.Current?.CurrentState?.Location?.ToString()
};
await _stateService.SaveAsync(state);
}
public async Task EnterForegroundAsync()
{
var state = await _stateService.RestoreAsync();
if (state is null)
return;
await RestoreStateAsync(state);
}
private static async Task RestoreStateAsync(AppState state)
{
if (string.IsNullOrWhiteSpace(state.CurrentRoute))
return;
await Shell.Current.GoToAsync(state.CurrentRoute);
}
}
Now the lifecycle callbacks no longer need to know how persistence works.
π§ Separating Capture from Persistence
There is one architectural issue with the previous example. The lifecycle coordinator is still reading directly from:
Shell.Current
For a small app that's acceptable, but we can make the design cleaner by separating state capture from state persistence. Create another abstraction:
public interface IAppStateProvider
{
AppState Capture();
Task RestoreAsync(AppState state);
}
Implementation:
public sealed class AppStateProvider : IAppStateProvider
{
public AppState Capture()
{
return new AppState
{
CurrentRoute =
Shell.Current?
.CurrentState?
.Location?
.ToString()
};
}
public async Task RestoreAsync(AppState state)
{
if (string.IsNullOrWhiteSpace(state.CurrentRoute))
return;
await Shell.Current.GoToAsync(state.CurrentRoute);
}
}
Now our coordinator becomes much cleaner:
public sealed class AppLifecycleCoordinator : IAppLifecycleCoordinator
{
private readonly IAppStateService _stateService;
private readonly IAppStateProvider _stateProvider;
public AppLifecycleCoordinator(
IAppStateService stateService,
IAppStateProvider stateProvider)
{
_stateService = stateService;
_stateProvider = stateProvider;
}
public async Task EnterBackgroundAsync()
{
var state = _stateProvider.Capture();
await _stateService.SaveAsync(state);
}
public async Task EnterForegroundAsync()
{
var state = await _stateService.RestoreAsync();
if (state is null)
return;
await _stateProvider.RestoreAsync(state);
}
}
The responsibilities are now clearly separated.
| Component | Responsibility |
|---|---|
AppState | Represents persistent lifecycle state |
IAppStateProvider | Captures/restores application state |
IAppStateService | Stores and retrieves state |
AppLifecycleCoordinator | Coordinates lifecycle transitions |
| MAUI lifecycle | Detects platform transitions |
π§ Registering the Complete Architecture
Our DI configuration becomes:
builder.Services.AddSingleton<IAppStateService, PreferencesAppStateService>();
builder.Services.AddSingleton<IAppStateProvider, AppStateProvider>();
builder.Services.AddSingleton<IAppLifecycleCoordinator, AppLifecycleCoordinator>();
Conceptually:
βββββββββββββββββββββββββββ
β MAUI Lifecycle Event β
ββββββββββββββ¬βββββββββββββ
β
βΌ
βββββββββββββββββββββββββββ
β Lifecycle Coordinator β
ββββββββββββββ¬βββββββββββββ
β
βββββββ΄ββββββ
βΌ βΌ
State Provider State Service
β β
βΌ βΌ
UI / Shell Preferences
This is still a small architecture, but it gives us useful separation.
π± Connecting Android Lifecycle Events
Now we can connect Android lifecycle events.
builder.ConfigureLifecycleEvents(events =>
{
#if ANDROID
events.AddAndroid(android =>
{
android.OnPause(activity =>
{
var coordinator =
IPlatformApplication.Current?
.Services
.GetService<IAppLifecycleCoordinator>();
_ = coordinator?.EnterBackgroundAsync();
});
android.OnResume(activity =>
{
var coordinator =
IPlatformApplication.Current?
.Services
.GetService<IAppLifecycleCoordinator>();
_ = coordinator?.EnterForegroundAsync();
});
});
#endif
});
The important distinction is that Android is only responsible for detecting the lifecycle event. It doesn't contain application persistence logic.
π What About iOS?
The same architectural idea applies to iOS. Platform lifecycle events differ, but they should ultimately invoke the same application-level coordinator. Conceptually:
Android OnPause βββββββ
β
Android OnResume ββββββ€
βΌ
AppLifecycleCoordinator
β²
β
iOS lifecycle βββββββββ€
β
Windows lifecycle βββββ
That is the primary advantage of introducing an application-level lifecycle abstraction. Your persistence behavior isn't duplicated across platforms.
π Persisting Draft State
Navigation is only one example. A more useful scenario is preserving an unfinished form. Suppose we have:
public sealed class ProfileDraft
{
public string? Name { get; set; }
public string? Email { get; set; }
public string? Biography { get; set; }
}
Our application state could contain:
public sealed class AppState
{
public string? CurrentRoute { get; set; }
public ProfileDraft? ProfileDraft { get; set; }
public DateTime LastUpdatedUtc { get; set; }
}
Now the user can partially complete a form, leave the application, and return later without losing everything.
β° Expiring Old State
Temporary state should not necessarily live forever.
Suppose someone returns three weeks later.
Restoring an old temporary navigation route or draft may be undesirable.
Because we stored:
LastUpdatedUtc
we can introduce expiration.
public async Task<AppState?> RestoreAsync(
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var json = Preferences.Default.Get<string?>(
StateKey,
null);
if (string.IsNullOrWhiteSpace(json))
return null;
var state = JsonSerializer.Deserialize<AppState>(json);
if (state is null)
return null;
var age = DateTime.UtcNow - state.LastUpdatedUtc;
if (age > TimeSpan.FromDays(7))
{
await ClearAsync();
return null;
}
return state;
}
Now lifecycle state automatically expires after seven days.
π Preferences vs SecureStorage
One common mistake is treating all persisted state equally.
Preferences should not be used for secrets.
For example:
CurrentRoute
Theme
SelectedTab
Draft search
are reasonable candidates. But:
AccessToken
RefreshToken
Password
Private API key
are not.
A simplified decision table:
| Data | Storage |
|---|---|
| Current route | Preferences |
| Selected tab | Preferences |
| Draft UI values | Preferences / local DB |
| Large offline state | SQLite |
| Access token | SecureStorage |
| Refresh token | SecureStorage |
| Permanent business data | Database |
| Derived UI state | Recalculate |
A lifecycle persistence service should never become a convenient dumping ground for sensitive data.
β οΈ Don't Persist Everything
It can be tempting to serialize an entire ViewModel:
JsonSerializer.Serialize(viewModel);
This usually becomes problematic.
ViewModels may contain:
- services
- commands
- observable collections
- navigation references
- cancellation tokens
- transient state
- event subscriptions
- derived properties
Instead, create a small DTO containing only the state that matters.
For example:
public sealed record CheckoutDraftState(
string? PromoCode,
string? Notes,
int SelectedDeliveryOption);
Then persist that DTO.
This keeps lifecycle persistence intentional.
π Avoid Restoring State Multiple Times
Lifecycle callbacks may happen more frequently than expected.
You don't want every foreground transition to blindly navigate again.
A simple guard can help:
private bool _restored;
public async Task EnterForegroundAsync()
{
if (_restored)
return;
var state = await _stateService.RestoreAsync();
if (state is null)
return;
await _stateProvider.RestoreAsync(state);
_restored = true;
}
The exact behavior depends on your application.
Some apps need restoration only during cold startup.
Others may refresh state whenever they return to the foreground.
The important part is to define the behavior explicitly.
π§Ή Clear State When It Is No Longer Needed
Suppose the state represents an unfinished checkout. Once checkout completes, remove it.
await _stateService.ClearAsync();
Otherwise the next application startup could restore something that is no longer relevant.
A good lifecycle state has a clear lifecycle of its own:
Capture
β
Persist
β
Application suspended/terminated
β
Restore
β
Consume
β
Clear
π§ͺ Making Lifecycle Persistence Testable
One major advantage of this architecture is that the coordinator can be tested without invoking Android or iOS lifecycle APIs. For example, create an in-memory state service:
public sealed class InMemoryAppStateService : IAppStateService
{
public AppState? State { get; private set; }
public Task SaveAsync(
AppState state,
CancellationToken cancellationToken = default)
{
State = state;
return Task.CompletedTask;
}
public Task<AppState?> RestoreAsync(
CancellationToken cancellationToken = default)
{
return Task.FromResult(State);
}
public Task ClearAsync()
{
State = null;
return Task.CompletedTask;
}
}
Now the lifecycle coordinator can be exercised independently from the platform. That means you can validate scenarios such as:
App enters background
β
State captured
β
State persisted
β
App returns
β
State loaded
β
UI restored
without launching an emulator.
π¨ Handle Corrupted State Gracefully
Persisted state should never prevent your application from starting.
For example:
public Task<AppState?> RestoreAsync(
CancellationToken cancellationToken = default)
{
try
{
cancellationToken.ThrowIfCancellationRequested();
var json = Preferences.Default.Get<string?>(
StateKey,
null);
if (string.IsNullOrWhiteSpace(json))
return Task.FromResult<AppState?>(null);
var state = JsonSerializer.Deserialize<AppState>(json);
return Task.FromResult(state);
}
catch (JsonException)
{
Preferences.Default.Remove(StateKey);
return Task.FromResult<AppState?>(null);
}
}
If the schema changes or persisted data becomes invalid, the app can discard the temporary state and continue normally.
For lifecycle state, this is usually much better than crashing during startup.
𧬠Versioning Persisted State
Applications evolve.
Imagine version 1 stores:
{
"currentRoute": "//profile"
}
Later, version 2 expects more information. You can version the state explicitly:
public sealed class AppState
{
public int Version { get; set; } = 1;
public string? CurrentRoute { get; set; }
public DateTime LastUpdatedUtc { get; set; }
}
Then:
if (state.Version != 1)
{
await ClearAsync();
return null;
}
For temporary lifecycle state, discarding incompatible data is often perfectly acceptable. For permanent business data, of course, you would normally implement migrations instead.
β‘ Keep Lifecycle Operations Fast
Lifecycle callbacks are not the right place for expensive operations.
Avoid doing things such as:
Upload 500 records
Download remote configuration
Refresh every API endpoint
Rebuild the database
Process large images
Perform expensive migrations
when the application enters the background. Lifecycle persistence should generally be:
Capture small state
β
Serialize
β
Write locally
β
Finish quickly
If you need heavier background synchronization, treat that as a separate architectural concern.
ποΈ A Production-Friendly Structure
A small project could organize the implementation like this:
Services/
β
βββ Lifecycle/
β βββ IAppLifecycleCoordinator.cs
β βββ AppLifecycleCoordinator.cs
β
βββ State/
β βββ IAppStateService.cs
β βββ PreferencesAppStateService.cs
β βββ IAppStateProvider.cs
β βββ AppStateProvider.cs
β
Models/
β
βββ AppState.cs
βββ ProfileDraft.cs
This keeps lifecycle orchestration separate from storage.
π Direct Lifecycle Handling vs State Service
| Approach | Direct callbacks | State architecture |
|---|---|---|
| Easy initially | β | β‘ |
| Platform independent | β | β |
| Unit testable | Difficult | β |
| Storage replaceable | β | β |
| Handles complex state | β οΈ | β |
| Clear responsibilities | β | β |
| Scales with application | β οΈ | β |
For a tiny application, direct callbacks may be enough. For an application where state restoration matters, a small abstraction pays off quickly.
π― Recommended Flow
A practical lifecycle persistence flow looks like this:
APPLICATION RUNNING
β
β background
βΌ
Capture AppState
β
βΌ
Serialize State
β
βΌ
Persist Locally
β
ββββββββββββ΄βββββββββββ
β β
Process survives Process killed
β β
ββββββββββββ¬βββββββββββ
β
βΌ
App becomes active
β
βΌ
Load AppState
β
βΌ
Validate State
β
ββββββββ΄βββββββ
β β
Valid Invalid
β β
βΌ βΌ
Restore Discard
β
βΌ
Continue session
The key is that restoring state should always be defensive. The application should still be capable of starting normally when no valid persisted state exists.
π‘ Final Thoughts
Application lifecycle handling in .NET MAUI isn't only about detecting when an application enters the foreground or background. The more important architectural question is:
What is the minimum state required to reconstruct a useful user experience?
A good lifecycle persistence implementation should be:
- π¦ Small
- β‘ Fast
- π§© Decoupled
- π Security-aware
- β»οΈ Recoverable
- π§ͺ Testable
- π± Platform-independent where possible Instead of placing persistence logic directly inside Android, iOS, or Windows lifecycle callbacks, introduce a small application-level boundary. The resulting architecture becomes:
Platform Lifecycle
β
Lifecycle Coordinator
β
State Provider
β
State Persistence
That small separation makes it much easier to evolve the application laterβwhether you decide to persist navigation, drafts, filters, workflows, or other transient user state.
The objective isn't to save everything.
It's to save just enough state to make returning to the application feel seamless. π
Was this useful?
Sign in to react. Guest comments are still welcome.




Comments (0)
No approved comments yet.