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?

Comments (0)

Leave a comment

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