Building a Reliable Outbox Pattern for Offline .NET MAUI Apps

๐Ÿ“ฌ Building a Reliable Outbox Pattern for Offline .NET MAUI Apps

Mobile applications live in an unreliable world.

A backend service normally runs inside a controlled environment with stable networking, persistent infrastructure, monitoring, and predictable connectivity. A mobile application does not have those guarantees.

The user can lose Wi-Fi in the middle of an operation. The operating system can suspend the application. The process can be terminated. A request can reach the server while the response never reaches the device. The user can switch between cellular and Wi-Fi. A synchronization operation can fail halfway through a batch.

For many applications, simply wrapping an HTTP request in try/catch is not enough. Consider a field-service application where a technician completes an inspection while offline:

    Inspection completed locally
            โ”‚
            โ–ผ
    POST /api/inspections
            โ”‚
            โ–ผ
    Network unavailable
            โ”‚
            โ–ผ
    Request fails

If the application only performs the HTTP request, the operation may be lost. We could save the inspection locally first:

    Save inspection to SQLite
            โ”‚
            โ–ผ
    Call API

But this introduces another problem. What happens if the application is terminated after saving the inspection but before sending the HTTP request?

    SQLite COMMIT
         โ”‚
         โ–ผ
    ๐Ÿ’ฅ Application terminated
         โ”‚
         X
    HTTP request never executed

Now the local database contains the operation, but nothing guarantees that the server will ever receive it.

There is an even more subtle failure.

Suppose the request reaches the server successfully, but the connection disappears before the response reaches the device:

    .NET MAUI App                  API
    
    POST /orders โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–บ
    
                             Order created
    
                 X โ—„โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ 201 Created
               network lost

The application believes the operation failed. It retries.

    POST /orders โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–บ
    
                             Order created again

Now we have duplicate data.

This is where the Outbox Pattern becomes extremely useful.

Instead of treating remote communication as an immediate side effect of a user action, we persist the intent to perform that remote operation alongside the local state.

The application can then deliver that intent reliably when conditions allow.

In this article, we'll build a production-oriented Outbox architecture for .NET MAUI applications using C#, SQLite-style persistence, idempotency, retries, exponential backoff, concurrency protection, lifecycle integration, observability, and offline synchronization. ๐Ÿš€


๐Ÿ“Œ Table of Contents

  1. The Reliability Problem
  2. Why try/catch Is Not Enough
  3. What Is the Outbox Pattern?
  4. The Mobile Outbox Variant
  5. The Dual-Write Problem
  6. Designing the Architecture
  7. Defining an Outbox Message
  8. Outbox States
  9. Persisting Business Data and Outbox Messages Atomically
  10. Designing the Outbox Repository
  11. Building the Outbox Processor
  12. Designing Message Handlers
  13. Serialization and Message Contracts
  14. Processing Messages Safely
  15. Preventing Concurrent Processing
  16. At-Least-Once Delivery
  17. Why Exactly-Once Delivery Is Difficult
  18. Idempotency Keys
  19. Server-Side Idempotency
  20. Retry Policies
  21. Exponential Backoff
  22. Adding Jitter
  23. Permanent vs Transient Failures
  24. Poison Messages
  25. Message Ordering
  26. Per-Aggregate Ordering
  27. Connectivity-Aware Processing
  28. App Lifecycle Integration
  29. Recovering After Application Restart
  30. Background Execution
  31. Authentication and the Outbox
  32. Conflict Resolution
  33. Attachments and Large Payloads
  34. Outbox Cleanup
  35. Observability
  36. Health Monitoring Integration
  37. User Experience
  38. Testing
  39. Failure Injection
  40. Security
  41. Performance
  42. Production Architecture
  43. Common Mistakes
  44. Best Practices
  45. Conclusion

1. ๐ŸŒ The Reliability Problem

Let's start with a simple application. The user creates an order:

    public async Task CreateOrderAsync(
        Order order,
        CancellationToken cancellationToken)
    {
        await _api.CreateOrderAsync(
            order,
            cancellationToken);
    }

This works perfectly when:

    Device
      โ”‚
      โ–ผ
    Network
      โ”‚
      โ–ผ
    API

are all functioning normally.

But mobile networks aren't reliable.

The request could fail because of:

    No internet connection
    DNS failure
    TLS failure
    Timeout
    Server unavailable
    Wi-Fi โ†’ cellular transition
    VPN reconnect
    Application suspension
    Process termination
    Authentication expiration
```text
We might add retry logic:

```csharp
    try
    {
        await _api.CreateOrderAsync(
            order,
            cancellationToken);
    }
    catch
    {
        // Retry later
    }

But where exactly is later stored?

If it's only represented by an in-memory Task, it disappears when the process disappears.

Reliable retry requires durable state.


2. โš ๏ธ Why try/catch Is Not Enough

Exception handling answers:

What should this execution path do when an operation fails?

It doesn't automatically answer:

How do I guarantee that the business operation remains pending after the application is terminated?

Consider:

    await _database.SaveOrderAsync(order);
    
    try
    {
        await _api.CreateOrderAsync(order);
    }
    catch (Exception ex)
    {
        _logger.LogError(
            ex,
            "Could not upload order.");
    }

The local operation succeeds.

The remote operation fails.

We now have two independent states:

    Local Database       Server
    โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€       โ”€โ”€โ”€โ”€โ”€โ”€
    
    Order exists         Order missing

Something must remember that synchronization is still required.

That "something" is the Outbox.


3. ๐Ÿ“ฌ What Is the Outbox Pattern?

The traditional Transactional Outbox Pattern is commonly used in distributed systems. Instead of performing:

    Database write
         +
    Message broker publish

as two independent operations, the application stores both:

    Business data
         +
    Outbox record

inside the same local transaction.

Conceptually:

    BEGIN TRANSACTION
    
    INSERT Order
    
    INSERT OutboxMessage
    
    COMMIT

Once committed, another component processes the Outbox.

    Outbox
       โ”‚
       โ–ผ
    Processor
       โ”‚
       โ–ผ
    Remote system

If delivery fails, the Outbox record remains.

If the application restarts, the Outbox record remains.

If connectivity disappears, the Outbox record remains.

The intent has become durable.


4. ๐Ÿ“ฑ The Mobile Outbox Variant

On the backend, an Outbox often bridges:

    Database โ†’ Message Broker

In a mobile application, we can adapt the same principle to:

    Local Database โ†’ REST API

or:

    Local Database โ†’ GraphQL API

or:

    Local Database โ†’ Synchronization Service

For example:

    User taps Save
          โ”‚
          โ–ผ
    Local Transaction
          โ”‚
          โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
          โ–ผ              โ–ผ
    Save Order      Create Outbox
                       Message
          โ”‚              โ”‚
          โ””โ”€โ”€โ”€โ”€ COMMIT โ”€โ”€โ”˜
                         โ”‚
                         โ–ผ
                    UI completes
                         โ”‚
                         โ–ผ
                  Outbox Processor
                         โ”‚
                 Connectivity?
                    โ”‚        โ”‚
                   No       Yes
                    โ”‚        โ”‚
                    โ–ผ        โ–ผ
                  Wait      API
                             โ”‚
                        Success?
                         โ”‚     โ”‚
                        Yes    No
                         โ”‚     โ”‚
                         โ–ผ     โ–ผ
                      Complete Retry

The important architectural shift is:

Saving the user's operation no longer depends on immediate network availability.


5. ๐Ÿ’ฅ The Dual-Write Problem

Without an Outbox, we often perform two writes:

    await _database.SaveOrderAsync(order);
    
    await _api.CreateOrderAsync(order);

These are two independent systems.

No transaction can normally cover both:

    SQLite transaction
    +
    Remote HTTP transaction

If the first succeeds and the second fails:

    Local = committed
    Remote = missing

If we reverse them:

    await _api.CreateOrderAsync(order);
    
    await _database.SaveOrderAsync(order);

we simply reverse the failure mode:

    Remote = committed
    Local = missing

This is the dual-write problem.

The Outbox doesn't create a distributed transaction.

Instead, it changes the problem.

We make the two local writes atomic:

    Business Data
    +
    Delivery Intent

Then remote delivery becomes asynchronous and retryable.


6. ๐Ÿ—๏ธ Designing the Architecture

Our production architecture will separate responsibilities.

    โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
    โ”‚                 .NET MAUI UI                  โ”‚
    โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                            โ”‚
                            โ–ผ
    โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
    โ”‚             Application / Use Case            โ”‚
    โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                            โ”‚
                            โ–ผ
                  Local Transaction
                     โ”‚             โ”‚
                     โ–ผ             โ–ผ
              Business Data     Outbox
                     โ”‚             โ”‚
                     โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                            โ”‚
                          COMMIT
                            โ”‚
                            โ–ผ
    โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
    โ”‚              Outbox Processor                 โ”‚
    โ”‚                                               โ”‚
    โ”‚  Retry                                        โ”‚
    โ”‚  Backoff                                      โ”‚
    โ”‚  Ordering                                     โ”‚
    โ”‚  Concurrency                                  โ”‚
    โ”‚  Cancellation                                 โ”‚
    โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                            โ”‚
                            โ–ผ
    โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
    โ”‚              Message Handlers                 โ”‚
    โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                            โ”‚
                            โ–ผ
                        REST API
                            โ”‚
                            โ–ผ
                     Idempotency Layer

This gives us clear boundaries:

Application layer creates business operations. Outbox repository persists delivery intent. Outbox processor schedules execution. Message handlers know how to perform specific remote operations. Backend idempotency prevents duplicate effects.


7. ๐Ÿ“ฆ Defining an Outbox Message

Let's define the persisted model.

    public sealed class OutboxMessage
    {
        public Guid Id { get; set; }
    
        public string Type { get; set; } = string.Empty;
    
        public string Payload { get; set; } = string.Empty;
    
        public OutboxMessageStatus Status { get; set; }
    
        public int AttemptCount { get; set; }
    
        public DateTimeOffset CreatedAt { get; set; }
    
        public DateTimeOffset? LastAttemptAt { get; set; }
    
        public DateTimeOffset? NextAttemptAt { get; set; }
    
        public DateTimeOffset? CompletedAt { get; set; }
    
        public string? LastError { get; set; }
    
        public string? AggregateId { get; set; }
    
        public string IdempotencyKey { get; set; } =
            string.Empty;
    }

Notice that we're storing more than a serialized request.

We also persist operational state.

That means the application can answer:

    When was this operation created?
    How many times has it failed?
    When should it retry?
    Has it completed?
    What entity does it belong to?
    What idempotency key identifies it?

8. ๐Ÿšฆ Outbox States

Define explicit states:

    public enum OutboxMessageStatus
    {
        Pending,
        Processing,
        Completed,
        Failed,
        DeadLetter
    }

A typical lifecycle is:

                  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
                  โ”‚  Pending  โ”‚
                  โ””โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”˜
                        โ”‚
                        โ–ผ
                  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
                  โ”‚Processing โ”‚
                  โ””โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”˜
                        โ”‚
              โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
              โ–ผ                   โ–ผ
         Successful             Failed
              โ”‚                   โ”‚
              โ–ผ                   โ–ผ
         Completed          Retry eligible?
                                โ”‚     โ”‚
                               Yes    No
                                โ”‚     โ”‚
                                โ–ผ     โ–ผ
                             Pending DeadLetter

Persisting these states is critical.

If they're only maintained in memory, application termination destroys the synchronization state.


9. ๐Ÿ” Persisting Business Data and Outbox Messages Atomically

This is the heart of the pattern. Suppose we're creating an order. We should not do this:

    await _orders.InsertAsync(order);
    
    await _outbox.EnqueueAsync(message);

If the process terminates between those calls:

    Order inserted
          โ”‚
          โ–ผ
    ๐Ÿ’ฅ Crash
          โ”‚
          X
    Outbox not inserted

The operation becomes permanently unsynchronized.

Instead, both writes need to participate in the same local database transaction.

Conceptually:

    await _database.RunInTransactionAsync(
        async transaction =>
        {
            await transaction.InsertAsync(order);
    
            await transaction.InsertAsync(
                CreateOutboxMessage(order));
        });

The invariant becomes:

Either:
    Order exists
    AND
    Outbox message exists

OR:

    Neither exists

That invariant is the foundation of reliable offline delivery.


10. ๐Ÿ“ฎ Creating the Outbox Message

A factory keeps message creation consistent.

    public static class OutboxMessageFactory
    {
        public static OutboxMessage Create<T>(
            string type,
            T payload,
            string? aggregateId = null)
        {
            var id = Guid.NewGuid();
    
            return new OutboxMessage
            {
                Id = id,
                Type = type,
                Payload = JsonSerializer.Serialize(payload),
                Status = OutboxMessageStatus.Pending,
                AttemptCount = 0,
                CreatedAt = DateTimeOffset.UtcNow,
                AggregateId = aggregateId,
                IdempotencyKey = id.ToString("N")
            };
        }
    }

For example:

    var message =
        OutboxMessageFactory.Create(
            "order.create.v1",
            new CreateOrderMessage(
                order.Id,
                order.CustomerId,
                order.Total),
            order.Id.ToString());

Notice the version:

    order.create.v1

Persisted messages can survive application upgrades.

Versioning message contracts from the beginning can save significant migration pain later.


11. ๐Ÿงพ Message Contracts

Avoid serializing arbitrary application entities directly. Instead of:

    JsonSerializer.Serialize(order);

prefer a stable message contract:

    public sealed record CreateOrderMessage(
        Guid OrderId,
        Guid CustomerId,
        decimal Total);

Why? Because your domain entity may evolve:

    Order v1
       โ”‚
       โ–ผ
    Application update
       โ”‚
       โ–ผ
    Order v2

while an old Outbox message remains pending.

Persisted messages are durable contracts.

Treat them accordingly.


12. ๐Ÿ—ƒ๏ธ Designing the Outbox Repository

Define persistence behind an interface.

    public interface IOutboxRepository
    {
        Task<IReadOnlyList<OutboxMessage>> GetPendingAsync(
            int batchSize,
            DateTimeOffset now,
            CancellationToken cancellationToken);
    
        Task MarkProcessingAsync(
            Guid id,
            CancellationToken cancellationToken);
    
        Task MarkCompletedAsync(
            Guid id,
            CancellationToken cancellationToken);
    
        Task MarkFailedAsync(
            Guid id,
            string error,
            DateTimeOffset nextAttemptAt,
            CancellationToken cancellationToken);
    
        Task MarkDeadLetterAsync(
            Guid id,
            string reason,
            CancellationToken cancellationToken);
    }

This keeps the processor independent from a particular SQLite library.

You could implement the repository using:

    sqlite-net-pcl
    Microsoft.Data.Sqlite
    Shaunebu.Data.SQLite
    another local persistence abstraction

without changing the processing architecture.


13. โš™๏ธ Building the Outbox Processor

Define the processor:

    public interface IOutboxProcessor
    {
        Task<OutboxProcessingResult> ProcessAsync(
            CancellationToken cancellationToken = default);
    }

And a result:

    public sealed record OutboxProcessingResult(
        int Processed,
        int Completed,
        int Failed,
        int DeadLettered);

The processor should operate in bounded batches.

    public sealed class OutboxProcessor : IOutboxProcessor
    {
        private readonly IOutboxRepository _repository;
        private readonly IOutboxDispatcher _dispatcher;
        private readonly SemaphoreSlim _gate = new(1, 1);
    
        public OutboxProcessor(
            IOutboxRepository repository,
            IOutboxDispatcher dispatcher)
        {
            _repository = repository;
            _dispatcher = dispatcher;
        }
    
        public async Task<OutboxProcessingResult> ProcessAsync(
            CancellationToken cancellationToken = default)
        {
            if (!await _gate.WaitAsync(
                    TimeSpan.Zero,
                    cancellationToken))
            {
                return new OutboxProcessingResult(
                    0, 0, 0, 0);
            }
    
            try
            {
                return await ProcessCoreAsync(
                    cancellationToken);
            }
            finally
            {
                _gate.Release();
            }
        }
    
        private async Task<OutboxProcessingResult> ProcessCoreAsync(
            CancellationToken cancellationToken)
        {
            var messages =
                await _repository.GetPendingAsync(
                    25,
                    DateTimeOffset.UtcNow,
                    cancellationToken);
    
            var completed = 0;
            var failed = 0;
            var deadLettered = 0;
    
            foreach (var message in messages)
            {
                cancellationToken.ThrowIfCancellationRequested();
    
                var result =
                    await ProcessMessageAsync(
                        message,
                        cancellationToken);
    
                switch (result)
                {
                    case MessageProcessingResult.Completed:
                        completed++;
                        break;
    
                    case MessageProcessingResult.Failed:
                        failed++;
                        break;
    
                    case MessageProcessingResult.DeadLetter:
                        deadLettered++;
                        break;
                }
            }
    
            return new OutboxProcessingResult(
                messages.Count,
                completed,
                failed,
                deadLettered);
        }
    }

The SemaphoreSlim prevents two triggers from processing the same queue simultaneously inside the same process.

That can happen easily when processing is triggered by:

    Application startup
    Connectivity restored
    User presses Sync
    Foreground transition
    Periodic timer

at nearly the same time.


14. ๐Ÿงฉ Designing Message Handlers

The processor shouldn't contain a massive switch statement with every API operation. Instead:

    public interface IOutboxMessageHandler
    {
        string MessageType { get; }
    
        Task HandleAsync(
            OutboxMessage message,
            CancellationToken cancellationToken);
    }

For example:

    public sealed class CreateOrderOutboxHandler
        : IOutboxMessageHandler
    {
        private readonly IOrdersApi _api;
    
        public string MessageType =>
            "order.create.v1";
    
        public CreateOrderOutboxHandler(
            IOrdersApi api)
        {
            _api = api;
        }
    
        public async Task HandleAsync(
            OutboxMessage message,
            CancellationToken cancellationToken)
        {
            var payload =
                JsonSerializer.Deserialize<CreateOrderMessage>(
                    message.Payload)
                ?? throw new InvalidOperationException(
                    "Invalid outbox payload.");
    
            await _api.CreateOrderAsync(
                payload,
                message.IdempotencyKey,
                cancellationToken);
        }
    }

Now new operations can be added without modifying the central processor.


15. ๐Ÿงญ Dispatching Messages

Create a dispatcher:

    public interface IOutboxDispatcher
    {
        Task DispatchAsync(
            OutboxMessage message,
            CancellationToken cancellationToken);
    }

Implementation:

    public sealed class OutboxDispatcher
        : IOutboxDispatcher
    {
        private readonly IReadOnlyDictionary<
            string,
            IOutboxMessageHandler> _handlers;
    
        public OutboxDispatcher(
            IEnumerable<IOutboxMessageHandler> handlers)
        {
            _handlers = handlers.ToDictionary(
                x => x.MessageType,
                StringComparer.Ordinal);
        }
    
        public Task DispatchAsync(
            OutboxMessage message,
            CancellationToken cancellationToken)
        {
            if (!_handlers.TryGetValue(
                    message.Type,
                    out var handler))
            {
                throw new UnknownOutboxMessageException(
                    message.Type);
            }
    
            return handler.HandleAsync(
                message,
                cancellationToken);
        }
    }

This gives us:

    Outbox Processor
          โ”‚
          โ–ผ
    Dispatcher
          โ”‚
          โ”œโ”€โ”€ order.create.v1
          โ”œโ”€โ”€ order.update.v1
          โ”œโ”€โ”€ inspection.submit.v1
          โ”œโ”€โ”€ profile.update.v1
          โ””โ”€โ”€ attachment.upload.v1

16. ๐Ÿ”„ Processing a Message Safely

The core execution might look like:

    private async Task<MessageProcessingResult> ProcessMessageAsync(
        OutboxMessage message,
        CancellationToken cancellationToken)
    {
        await _repository.MarkProcessingAsync(
            message.Id,
            cancellationToken);
    
        try
        {
            await _dispatcher.DispatchAsync(
                message,
                cancellationToken);
    
            await _repository.MarkCompletedAsync(
                message.Id,
                cancellationToken);
    
            return MessageProcessingResult.Completed;
        }
        catch (OperationCanceledException)
            when (cancellationToken.IsCancellationRequested)
        {
            throw;
        }
        catch (Exception ex)
        {
            // Classification comes later.
    
            await _repository.MarkFailedAsync(
                message.Id,
                ex.Message,
                DateTimeOffset.UtcNow.AddMinutes(1),
                cancellationToken);
    
            return MessageProcessingResult.Failed;
        }
    }

But there is an important reliability issue here.

Suppose this happens:

    API request succeeds
           โ”‚
           โ–ผ
    Server commits operation
           โ”‚
           โ–ผ
    ๐Ÿ’ฅ App terminates
           โ”‚
           X
    MarkCompletedAsync never executes

When the app restarts, the message is still pending or processing.

It will be sent again.

This is why the Outbox naturally leads us to at-least-once delivery.


17. ๐Ÿ“ฌ At-Least-Once Delivery

With durable retry, the system can generally guarantee:

A pending operation will continue being attempted until it succeeds or is classified as permanently failed.

But the same message may be delivered more than once.

    Attempt #1
        โ”‚
        โ–ผ
    Server processes request
        โ”‚
        X
    Response lost
    
    Attempt #2
        โ”‚
        โ–ผ
    Same request delivered again

Therefore:

    Reliable Outbox
          +
    Retries
          =
    Possible duplicate delivery

The receiving system must account for this.


18. ๐ŸŽฏ Why Exactly-Once Delivery Is Difficult

It is tempting to say:

    Send every operation exactly once.

But consider the network failure window again.

The client cannot know whether:

    Request never reached server

or:

    Request succeeded but response was lost

Both can appear as:

    TimeoutException

from the client's perspective.

Retrying is necessary for the first case.

Retrying can duplicate the second case.

Therefore, instead of trying to make transport magically exactly-once, we typically combine:

    At-least-once delivery
            +
    Idempotent processing

to achieve the desired business effect.


19. ๐Ÿชช Idempotency Keys

Each logical operation receives a stable identifier. We already created:

    IdempotencyKey = id.ToString("N");

The client sends it with the request. For example:

    POST /api/orders
    Idempotency-Key: 46dc7d83d2744d84a9c9f36f37e67b90

Every retry uses the same key. Not:

    Attempt 1 โ†’ key A
    Attempt 2 โ†’ key B
    Attempt 3 โ†’ key C

but:

    Attempt 1 โ”€โ”
    Attempt 2 โ”€โ”ผโ”€โ”€โ–บ 46dc7d83...
    Attempt 3 โ”€โ”˜

The key represents the logical operation, not the individual HTTP attempt.


20. ๐Ÿ–ฅ๏ธ Server-Side Idempotency

The backend must participate. Conceptually:

    if (await idempotencyStore.ExistsAsync(key))
    {
        return previousResult;
    }
    
    var result =
        await orderService.CreateAsync(request);
    
    await idempotencyStore.StoreAsync(
        key,
        result);
    
    return result;

A production implementation needs stronger transactional guarantees than this simplified example, but the principle is important.

The server remembers:

    Idempotency-Key XYZ
            โ”‚
            โ–ผ
    Already processed
            โ”‚
            โ–ผ
    Don't perform business side effect again

Now:

    Client retries 5 times

does not mean:

    Create 5 orders

It still represents one logical operation.


21. ๐Ÿ” Retry Policies

Not every failure should retry immediately. Consider:

    Attempt
      โ”‚
      โ–ผ
    Failure
      โ”‚
      โ–ผ
    Retry immediately
      โ”‚
      โ–ผ
    Failure
      โ”‚
      โ–ผ
    Retry immediately

If the API is unavailable for ten minutes, aggressive retries waste:

    Battery
    Network
    CPU
    Server capacity

A better strategy is delayed retry.


22. ๐Ÿ“ˆ Exponential Backoff

A common retry schedule grows exponentially. For example:

    Attempt 1 โ†’ 2 seconds
    Attempt 2 โ†’ 4 seconds
    Attempt 3 โ†’ 8 seconds
    Attempt 4 โ†’ 16 seconds
    Attempt 5 โ†’ 32 seconds

We can calculate:

    public static TimeSpan CalculateBackoff(
        int attempt)
    {
        var seconds = Math.Min(
            Math.Pow(2, attempt),
            300);
    
        return TimeSpan.FromSeconds(seconds);
    }

The maximum prevents the delay from growing indefinitely.

For mobile synchronization, real intervals may be much larger depending on the workload.


23. ๐ŸŽฒ Adding Jitter

Imagine 100,000 devices reconnect after an outage. Without jitter:

    All devices retry at 10:00:00
    All fail
    
    All retry at 10:00:02
    All fail
    
    All retry at 10:00:06

We have created synchronized retry storms. Add randomness:

    public static TimeSpan CalculateBackoffWithJitter(
        int attempt)
    {
        var baseSeconds =
            Math.Min(
                Math.Pow(2, attempt),
                300);
    
        var jitter =
            Random.Shared.NextDouble() * 2;
    
        return TimeSpan.FromSeconds(
            baseSeconds + jitter);
    }

Now devices spread their retry attempts.

    Device A โ†’ 8.4s
    Device B โ†’ 9.1s
    Device C โ†’ 8.8s
    Device D โ†’ 10.0s

This is better for both the device and the backend.


24. ๐Ÿšจ Transient vs Permanent Failures

Retrying everything is dangerous. Consider:

    HTTP 503

Likely transient.

Retry.

But:

    HTTP 400

may indicate an invalid payload.

Retrying the exact same invalid request 500 times won't fix it.

We need failure classification.

    public enum OutboxFailureType
    {
        Transient,
        Permanent,
        Authentication,
        Conflict,
        Unknown
    }

A classifier might inspect:

    Timeout
    Network unavailable
    HTTP status
    Authentication failure
    Validation failure
    Serialization failure

Then processing policy can differ.


25. ๐Ÿงฏ Example Failure Policy

A practical policy might look like:

Failure Classification Action
No connectivity Transient Wait
Timeout Transient Retry
HTTP 429 Transient Backoff
HTTP 500 Transient Retry
HTTP 503 Transient Retry
HTTP 400 Permanent Dead-letter
HTTP 401 Authentication Refresh/auth flow
HTTP 403 Permanent/authorization Stop
HTTP 409 Conflict Conflict policy
Invalid payload Permanent Dead-letter

The exact policy depends on the API contract.


26. โ˜ ๏ธ Poison Messages

A poison message is an operation that repeatedly fails and prevents useful progress. For example:

    Message A โ†’ invalid payload
    Message B โ†’ valid
    Message C โ†’ valid

If processing always stops at A:

    A fails
    โ†“
    stop

then B and C may never execute.

After a defined number of attempts, we may move A to:

    DeadLetter

Example:

    if (message.AttemptCount >= 10)
    {
        await _repository.MarkDeadLetterAsync(
            message.Id,
            "Maximum retry count exceeded.",
            cancellationToken);
    
        return MessageProcessingResult.DeadLetter;
    }

But don't blindly dead-letter operations simply because a server was unavailable for a long period.

Retry count alone isn't always enough.

Consider:

    Failure type
    Elapsed age
    Connectivity state
    Server response
    Business criticality

when defining the policy.


27. ๐Ÿงญ Message Ordering

Suppose the user performs:

    1. Create customer
    2. Update customer
    3. Delete customer

If the Outbox sends them as:

    3
    1
    2

the final server state may be incorrect.

Some operations require ordering.

A simple strategy is:

    ORDER BY CreatedAt ASC

But global ordering can unnecessarily serialize unrelated work.


28. ๐Ÿ”‘ Per-Aggregate Ordering

Suppose we have:

    Customer A:
      Create
      Update
      Update
    
    Customer B:
      Create
      Update

Operations for A may require ordering relative to each other.

Operations for B may require ordering relative to B.

But A and B may potentially execute independently.

This is why AggregateId can be useful.

    Aggregate A
      โ”œโ”€โ”€ Message 1
      โ”œโ”€โ”€ Message 2
      โ””โ”€โ”€ Message 3
    
    Aggregate B
      โ”œโ”€โ”€ Message 1
      โ””โ”€โ”€ Message 2

A more advanced Outbox processor can preserve ordering per aggregate while allowing bounded parallelism across aggregates.


29. โšก Should We Process in Parallel?

Parallel processing improves throughput. But this:

    await Task.WhenAll(
        messages.Select(ProcessMessageAsync));

can be dangerous.

Potential problems:

    Ordering violations
    Database contention
    API rate limits
    Battery usage
    Network saturation
    Race conditions

For many mobile applications, sequential or low-concurrency processing is preferable.

If concurrency is needed, make it explicit and bounded.

For example:

    Maximum parallelism = 2 or 3

rather than launching hundreds of requests.

Mobile synchronization is not a data-center batch processor.


30. ๐Ÿ“ถ Connectivity-Aware Processing

.NET MAUI exposes connectivity information that can be used as a signal. Conceptually:

    if (Connectivity.Current.NetworkAccess
        != NetworkAccess.Internet)
    {
        return;
    }

But remember:

    NetworkAccess.Internet

does not guarantee:

    Your API is reachable.

Connectivity is useful for deciding:

Is attempting remote delivery reasonable right now?

The HTTP result still determines actual delivery success.


31. ๐Ÿ”” Reacting to Connectivity Changes

Instead of constantly polling:

    Is internet available?
    Is internet available?
    Is internet available?

we can react to connectivity changes.

Conceptually:

    Connectivity.Current.ConnectivityChanged +=
        OnConnectivityChanged;

Then:

    private async void OnConnectivityChanged(
        object? sender,
        ConnectivityChangedEventArgs e)
    {
        if (e.NetworkAccess != NetworkAccess.Internet)
            return;
    
        try
        {
            await _outboxProcessor.ProcessAsync();
        }
        catch (Exception ex)
        {
            _logger.LogError(
                ex,
                "Outbox processing failed after connectivity change.");
        }
    }

In production, lifecycle ownership and event unsubscription must be handled carefully.

Also remember that async void should generally be limited to event-handler boundaries.


32. ๐Ÿ“ฑ App Lifecycle Integration

The Outbox processor can have several triggers:

    App startup
          โ”‚
          โ–ผ
    Process pending messages
    
    App resumes
          โ”‚
          โ–ผ
    Process pending messages
    
    Connectivity restored
          โ”‚
          โ–ผ
    Process pending messages
    
    User requests sync
          โ”‚
          โ–ผ
    Process pending messages

The important point is that these are triggers, not separate processors.

All should converge on the same concurrency-safe Outbox service.

    Startup โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
    Resume โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
    Connectivity โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ–บ OutboxProcessor
    Manual Sync โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

This is why duplicate execution protection matters.


33. ๐Ÿ”„ Recovering After Application Restart

Suppose the application terminates while a message is:

    Processing

On restart, that record cannot remain permanently stuck.

We need recovery semantics.

One strategy is a processing lease.

Add:

    public DateTimeOffset? ProcessingStartedAt { get; set; }

When processing begins:

    Status = Processing
    ProcessingStartedAt = now

At startup, inspect stale records:

    Status = Processing
    AND
    ProcessingStartedAt < now - timeout

Those records can be returned to:

    Pending

because the previous process no longer owns them.


34. ๐Ÿ” Processing Leases

A more explicit model uses:

    public string? LeaseId { get; set; }
    
    public DateTimeOffset? LeaseExpiresAt { get; set; }

Processor:

    Acquire message
          โ”‚
          โ–ผ
    LeaseId = processor-instance
    LeaseExpiresAt = now + 2 minutes

If the process dies, the lease eventually expires.

Another processor can reclaim the operation.

For a single-device local database, this may be more machinery than many applications need, but the concept becomes valuable as synchronization complexity increases.


35. ๐Ÿ”‘ Authentication and the Outbox

Queued operations can survive longer than access tokens. Example:

    08:00 User creates operation offline
    08:30 Access token expires
    10:00 Connectivity returns

The Outbox processor shouldn't assume the token captured at 08:00 remains valid.

More importantly:

Never persist access tokens inside Outbox payloads.

Persist business intent:

    {
      "orderId": "...",
      "customerId": "...",
      "total": 125.50
    }

not:

    {
      "accessToken": "eyJ...",
      "orderId": "..."
    }

Authentication should be resolved at execution time by the HTTP/authentication layer.


36. ๐Ÿ” What Happens on 401?

Suppose delivery returns:

    401 Unauthorized

A reasonable architecture is:

    Outbox Handler
          โ”‚
          โ–ผ
    HTTP pipeline
          โ”‚
          โ–ผ
    401
          โ”‚
          โ–ผ
    Authentication Service
          โ”‚
          โ–ผ
    Refresh token
       โ”‚        โ”‚
     Success   Failure
       โ”‚        โ”‚
       โ–ผ        โ–ผ
     Retry    Pause Outbox
               +
           Require login

Avoid having every Outbox handler implement its own authentication logic.

Authentication belongs in a shared HTTP/authentication layer.


37. โš”๏ธ Conflict Resolution

Offline operation introduces another problem. Suppose:

    Device:
    Customer.Name = "Alice Smith"

while offline.

Meanwhile another client changes the server:

    Server:
    Customer.Name = "Alice Johnson"

When the device reconnects, which value wins?

The Outbox guarantees delivery.

It does not automatically resolve business conflicts.

These are separate concerns.

Possible policies include:

    Client wins
    Server wins
    Last-write-wins
    Version-based concurrency
    ETags
    Manual merge
    Field-level merge
    Domain-specific conflict resolution

For important business data, explicit versioning is usually preferable to silently overwriting newer state.


38. ๐Ÿงฌ Optimistic Concurrency

Suppose the client downloaded:

    Order version = 12

The update message can include:

    public sealed record UpdateOrderMessage(
        Guid OrderId,
        int ExpectedVersion,
        string Status);

The server receives:

    ExpectedVersion = 12

but current server state is:

    Version = 14

Instead of silently overwriting data, the server returns a conflict.

    409 Conflict

The Outbox can classify this as:

    Conflict

and route it through a separate resolution policy.


39. ๐Ÿ“Ž Attachments and Large Payloads

Do not automatically store huge binary payloads directly in the Outbox table. Imagine:

    10 MB image
    ร—
    100 pending uploads

Now your Outbox contains roughly:

    1 GB

of serialized payloads.

A better model is often:

    Outbox Message
         โ”‚
         โ–ผ
    Metadata
         โ”‚
         โ”œโ”€โ”€ File path
         โ”œโ”€โ”€ Content type
         โ”œโ”€โ”€ Size
         โ””โ”€โ”€ Hash

For example:

    public sealed record UploadPhotoMessage(
        Guid InspectionId,
        string LocalFilePath,
        string ContentType,
        string Sha256);

The actual file remains in application-managed storage.

The Outbox contains the durable instruction to upload it.


40. โš ๏ธ File Lifetime Matters

If an Outbox message references:

    /cache/photo.jpg

the operating system may delete it before synchronization.

Durable Outbox operations require durable resources. For pending uploads, use application-owned persistent storage appropriate for the platform and your data model.

Then clean up the file only after:

    Upload succeeded
    AND
    Outbox message completed

41. ๐Ÿงน Outbox Cleanup

Completed messages shouldn't remain forever unless there is a specific audit requirement. Otherwise:

    10 operations/day
    โ†’ 3,650/year
    
    1,000 operations/day
    โ†’ 365,000/year

A cleanup policy might delete:

    Completed messages older than 7 days

or:

    Completed messages older than 30 days

depending on diagnostic and auditing requirements.

For example:

    public interface IOutboxMaintenanceService
    {
        Task<int> DeleteCompletedBeforeAsync(
            DateTimeOffset threshold,
            CancellationToken cancellationToken);
    }

Do not automatically delete:

    Failed
    DeadLetter
    Conflict

records before they have been appropriately handled or diagnosed.


42. ๐Ÿ“Š Outbox Diagnostics

Expose operational state.

    public sealed record OutboxDiagnostics(
        int PendingCount,
        int ProcessingCount,
        int FailedCount,
        int DeadLetterCount,
        DateTimeOffset? OldestPendingMessage,
        DateTimeOffset? LastSuccessfulDelivery);

Now the application can understand:

    Pending:          17
    Processing:       1
    Failed:           2
    Dead Letter:      0
    Oldest Pending:   8 minutes
    Last Delivery:    24 seconds ago

This becomes valuable for both support and automated health monitoring.


43. ๐Ÿฉบ Integrating with Application Health Monitoring

The Outbox is a perfect health signal. Suppose:

    Pending = 3
    Oldest = 2 minutes

That's probably healthy.

But:

    Pending = 2,714
    Oldest = 31 hours
    Last successful delivery = 29 hours

Something is wrong.

A health check might classify:

    Healthy:
    queue progressing normally
    
    Degraded:
    queue growing / delayed
    
    Unhealthy:
    no successful delivery for excessive period
    
    Unknown:
    no synchronization attempt yet

This integrates naturally with a production health-monitor architecture.


44. ๐Ÿ“ Structured Logging

Outbox events should be logged structurally. Instead of:

    _logger.LogInformation(
        "Message processed.");

prefer:

    _logger.LogInformation(
        "Outbox message {MessageId} of type {MessageType} completed after {AttemptCount} attempts",
        message.Id,
        message.Type,
        message.AttemptCount);

For failures:

    _logger.LogWarning(
        ex,
        "Outbox message {MessageId} failed on attempt {AttemptCount}",
        message.Id,
        message.AttemptCount);

Useful dimensions include:

    MessageId
    MessageType
    AggregateId
    AttemptCount
    FailureType
    Duration
    QueueAge
    Status

Avoid logging the complete payload by default.

It may contain sensitive business information.


45. ๐Ÿ“ˆ Useful Metrics

Useful Outbox metrics include:

    outbox_pending_count
    outbox_failed_count
    outbox_deadletter_count
    outbox_delivery_duration
    outbox_message_age
    outbox_retry_count
    outbox_delivery_success_count

One particularly important metric is:

    age of oldest pending message

Why? Because:

    PendingCount = 500

might be completely normal during a large synchronization. But:

    OldestPendingAge = 72 hours

strongly suggests stalled delivery.


46. ๐Ÿ‘ค User Experience

The Outbox should improve UX rather than expose infrastructure terminology. Don't show:

    Outbox message 9f3a... is Pending.

Show:

    Saved offline

or:

    3 changes waiting to sync

or:

    All changes synchronized

A useful model might be:

    public enum SynchronizationState
    {
        Synchronized,
        Pending,
        Synchronizing,
        Failed
    }

Then map internal Outbox state into user-facing state.


47. โšก Optimistic UI

The Outbox works particularly well with optimistic UI. When the user creates an order:

    Tap Save
        โ”‚
        โ–ผ
    Local transaction
        โ”‚
        โ”œโ”€โ”€ Save Order
        โ””โ”€โ”€ Save Outbox
        โ”‚
        โ–ผ
    COMMIT
        โ”‚
        โ–ผ
    UI immediately shows order
        โ”‚
        โ–ผ
    Background delivery later

The user doesn't need to wait for the network before continuing.

This can make offline-first applications feel dramatically faster.

But the UI should still represent synchronization status when relevant.


48. ๐Ÿงช Testing the Outbox

This architecture deserves serious tests. At minimum, test:

    Message persisted
    Message delivered
    Failed message retained
    Retry scheduling
    Permanent failure
    Dead-letter behavior
    Cancellation
    Unknown message type
    Malformed payload
    Concurrent processor invocation
    Ordering
    Idempotency key stability
    Application restart recovery

49. ๐Ÿงช Testing Atomic Persistence

One of the most important tests verifies:

    Business entity
    +
    Outbox message

are atomic.

Failure scenario:

    Insert business entity
          โ”‚
          โ–ผ
    Inject failure
          โ”‚
          X
    Insert Outbox

Expected:

    Transaction rolls back
    
    Business entity = absent
    Outbox message = absent

The inverse should also be impossible. You should never observe:

    Business entity exists
    Outbox missing

for an operation that requires synchronization.


50. ๐Ÿงช Testing Duplicate Delivery

Simulate:

    Server processes request
            โ”‚
            โ–ผ
    Client receives timeout

Then retry the same Outbox message.

Expected server state:

    1 business operation

not:

    2 business operations

This verifies the complete reliability contract:

    Outbox
    +
    Retry
    +
    Idempotency

Testing only the client isn't enough to validate this property.


51. ๐Ÿ’ฃ Failure Injection

Production reliability patterns become much easier to trust when failure is deliberately injected during tests. Test failures at:

    Before local transaction
    During local transaction
    After local COMMIT
    Before HTTP request
    During HTTP request
    After server commit
    Before client receives response
    Before MarkCompleted
    During cleanup
    During application restart

The most interesting reliability bugs usually live between steps, not inside the happy path.


52. ๐Ÿ”’ Security

An Outbox is persistent storage. That means its contents may survive:

    Application restart
    Device reboot
    Long offline periods
    Application upgrades

Don't store unnecessary secrets.

Avoid:

    Passwords
    Access tokens
    Refresh tokens
    Raw authorization headers
    Sensitive credentials

If queued business data itself is sensitive, consider the security guarantees required for local storage and whether additional encryption is appropriate for your threat model.

Also sanitize diagnostic output. This:

    MessageType = payment.submit.v1
    Status = Failed
    HTTP = 503

may be appropriate.

Dumping the complete payment payload into logs probably isn't.


53. ๐Ÿš€ Performance

An Outbox should improve reliability without making every operation expensive. Important considerations include:

    Database indexes
    Batch sizes
    Serialization cost
    Transaction duration
    Queue scanning
    Cleanup frequency
    Parallelism
    Payload size

Useful indexes may include:

    Status
    NextAttemptAt
    CreatedAt
    AggregateId

because the processor frequently needs queries conceptually similar to:

    WHERE Status = 'Pending'
    AND NextAttemptAt <= @Now
    ORDER BY CreatedAt
    LIMIT @BatchSize

Without appropriate indexing, a large Outbox can become increasingly expensive to scan.


54. ๐Ÿ“ฆ Batch Processing

Don't load every pending operation into memory. Instead:

    10,000 pending messages

should become:

    Batch 1 โ†’ 25
    Batch 2 โ†’ 25
    Batch 3 โ†’ 25
    ...

This bounds:

    Memory
    Database reads
    Network activity
    Processing time

and gives the lifecycle an opportunity to interrupt processing cleanly.


55. ๐Ÿ”‹ Battery Awareness

Reliable doesn't mean aggressive. A mobile Outbox shouldn't constantly wake the device trying to deliver low-priority work. Potential signals include:

    Application foreground state
    Connectivity
    Battery state
    Message priority
    Queue age
    User action
    Platform background constraints

For example, a user-submitted critical operation may justify immediate delivery.

Telemetry might not.

The Outbox processor can eventually support priority:

    public enum OutboxPriority
    {
        Low,
        Normal,
        High,
        Critical
    }

But avoid introducing complexity until the application actually needs it.


56. ๐Ÿง  Outbox vs Generic Background Queue

A generic in-memory queue might look like:

    Channel<Func<Task>>

and can be excellent for process-local work.

But it doesn't automatically provide durable delivery.

    Application terminated
           โ”‚
           โ–ผ
    Memory disappears
           โ”‚
           โ–ผ
    Queue disappears

An Outbox is different because the work intent is persisted.

Capability In-Memory Queue Persistent Outbox
Fast execution โœ… โœ…
Survives app restart โŒ โœ…
Offline delivery โŒ โœ…
Durable retry state โŒ โœ…
Auditable pending work โŒ โœ…
Requires persistence โŒ โœ…

Use the right abstraction for the reliability requirement.


57. ๐Ÿ”„ Outbox vs Retry Policy

A retry policy answers:

How should this operation retry during this execution?

An Outbox answers:

How do we preserve this operation so it can still execute after the current process, network session, or application lifecycle ends?

They complement each other.

    Outbox
       โ”‚
       โ–ผ
    Durable operation
       โ”‚
       โ–ผ
    Processor
       โ”‚
       โ–ผ
    Retry Policy
       โ”‚
       โ–ผ
    HTTP

Retry without persistence is not durable delivery. Outbox without sensible retry can create unnecessary network pressure.


58. ๐Ÿ—ƒ๏ธ Outbox vs Offline Database

Having SQLite doesn't automatically make an application offline-first. This:

    await database.SaveAsync(order);

solves local persistence.

It doesn't answer:

    How does the server eventually receive this?
    How are retries tracked?
    How are duplicates prevented?
    How are conflicts handled?
    What happens after restart?

Offline-first architecture requires coordination between:

    Local state
    Remote state
    Synchronization
    Conflict policy
    Delivery guarantees

The Outbox handles an important part of that problem.


59. ๐Ÿ—๏ธ Production Architecture

Putting everything together:

    โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
    โ”‚                    .NET MAUI UI                       โ”‚
    โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                               โ”‚
                               โ–ผ
    โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
    โ”‚                Application Use Case                   โ”‚
    โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                               โ”‚
                               โ–ผ
                      LOCAL TRANSACTION
                  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
                  โ”‚                         โ”‚
                  โ–ผ                         โ–ผ
           Business Tables              Outbox Table
                  โ”‚                         โ”‚
                  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                               โ”‚
                             COMMIT
                               โ”‚
                               โ–ผ
                        Local operation
                           complete
                               โ”‚
                               โ–ผ
    โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
    โ”‚                 Outbox Processor                      โ”‚
    โ”‚                                                       โ”‚
    โ”‚  Batch selection                                      โ”‚
    โ”‚  Concurrency gate                                     โ”‚
    โ”‚  Retry scheduling                                     โ”‚
    โ”‚  Backoff + jitter                                     โ”‚
    โ”‚  Failure classification                               โ”‚
    โ”‚  Ordering                                             โ”‚
    โ”‚  Dead-letter policy                                   โ”‚
    โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                               โ”‚
                               โ–ผ
    โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
    โ”‚                Outbox Dispatcher                      โ”‚
    โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                               โ”‚
              โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
              โ–ผ                โ–ผ                โ–ผ
         Order Handler   Inspection Handler  Upload Handler
              โ”‚                โ”‚                โ”‚
              โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                               โ”‚
                               โ–ผ
                           HTTP Layer
                               โ”‚
                        Authentication
                               โ”‚
                               โ–ผ
                           REST API
                               โ”‚
                               โ–ผ
                     Idempotency Handler
                               โ”‚
                               โ–ผ
                        Business Logic
                               โ”‚
                               โ–ผ
                           Database

And surrounding the pipeline:

                    โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
                    โ”‚   Connectivity   โ”‚
                    โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                             โ”‚
                             โ–ผ
    Lifecycle โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–บ Outbox Processor โ—„โ”€โ”€โ”€โ”€ Manual Sync
                             โ”‚
                             โ–ผ
                       Diagnostics
                             โ”‚
                 โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
                 โ–ผ           โ–ผ           โ–ผ
              Logging     Telemetry    Health

This is no longer simply a retry mechanism.

It is a durable delivery subsystem.


60. โš ๏ธ Common Mistake: Saving the Outbox Separately

This:

    await SaveOrderAsync();
    
    await AddOutboxMessageAsync();

does not provide the central Outbox guarantee.

There is still a failure window.

Both writes must be part of the same local transaction when they represent one atomic business operation.


61. โš ๏ธ Common Mistake: Generating a New Idempotency Key on Retry

Never do:

    foreach (var attempt in retries)
    {
        var key = Guid.NewGuid();
    
        await SendAsync(key);
    }

Every attempt now looks like a new business operation.

Generate the key when the Outbox message is created and persist it.

    Logical operation
          โ”‚
          โ–ผ
    Stable Idempotency Key
          โ”‚
          โ”œโ”€โ”€ Attempt 1
          โ”œโ”€โ”€ Attempt 2
          โ””โ”€โ”€ Attempt N

62. โš ๏ธ Common Mistake: Infinite Immediate Retry

This is not resilience:

    while (true)
    {
        try
        {
            await SendAsync();
            break;
        }
        catch
        {
        }
    }

It's a battery-draining denial-of-service loop against your own backend. ๐Ÿ˜… Use:

    Failure classification
    Backoff
    Jitter
    Maximum policy where appropriate
    Connectivity signals
    Cancellation

63. โš ๏ธ Common Mistake: Treating Every 4xx as Permanent

Although many client errors indicate permanent problems, semantics matter. For example:

    401

may be recoverable through token refresh.

    408

may be transient.

    409

may require conflict resolution.

429

explicitly asks the client to slow down. HTTP status alone isn't always enough; interpret it according to your API contract.


64. โš ๏ธ Common Mistake: Assuming Connectivity Means Success

This:

    Connectivity.Current.NetworkAccess
        == NetworkAccess.Internet

is a useful signal.

It doesn't prove:

    DNS works
    API works
    TLS works
    Authentication works
    Server is healthy

Always treat actual request results as authoritative for delivery.


65. โš ๏ธ Common Mistake: Blocking the UI Until Synchronization

If the operation can safely be performed offline, avoid:

    User taps Save
          โ”‚
          โ–ผ
    Wait for network
          โ”‚
          โ–ผ
    Wait for API
          โ”‚
          โ–ผ
    Save complete

The Outbox allows:

    User taps Save
          โ”‚
          โ–ผ
    Atomic local persistence
          โ”‚
          โ–ผ
    UI continues
          โ”‚
          โ–ผ
    Remote synchronization later

This is one of the major UX advantages of the pattern.


66. โš ๏ธ Common Mistake: Confusing Delivery with Business Success

Suppose an Outbox message reaches the server successfully, but the server rejects the business operation because of a conflict. Technically:

    Transport succeeded.

Business-wise:

    Operation did not complete.

Your synchronization model needs to distinguish:

    Transport failure
    Authentication failure
    Validation failure
    Concurrency conflict
    Business rejection
    Successful completion

A simple boolean:

    bool Success;

often becomes insufficient.


67. ๐Ÿ† Best Practices

When implementing an Outbox in .NET MAUI:

  1. ๐Ÿ“ฌ Persist remote delivery intent durably.
  2. ๐Ÿ” Save business data and its Outbox message in the same local transaction.
  3. ๐Ÿงพ Use explicit, versioned message contracts.
  4. ๐Ÿชช Generate a stable idempotency key per logical operation.
  5. ๐Ÿ–ฅ๏ธ Implement idempotency on the serverโ€”not only the client.
  6. ๐Ÿ” Assume at-least-once delivery.
  7. ๐Ÿ“ˆ Use exponential backoff for transient failures.
  8. ๐ŸŽฒ Add jitter to avoid synchronized retry storms.
  9. ๐Ÿšจ Distinguish transient, permanent, authentication, and conflict failures.
  10. โ˜ ๏ธ Provide a strategy for poison messages.
  11. ๐Ÿงญ Preserve ordering where business semantics require it.
  12. โšก Use bounded concurrency rather than unlimited parallelism.
  13. ๐Ÿ“ถ Treat connectivity as a hint, not proof of API availability.
  14. ๐Ÿ“ฑ Integrate processing with lifecycle events carefully.
  15. ๐Ÿ”„ Recover stale Processing messages after restart.
  16. ๐Ÿ”‘ Resolve authentication at delivery time.
  17. ๐Ÿ”’ Never persist access tokens inside Outbox payloads.
  18. โš”๏ธ Treat conflict resolution as a separate architectural concern.
  19. ๐Ÿ“Ž Store large binary payloads outside the Outbox table when appropriate.
  20. ๐Ÿงน Clean completed records according to a retention policy.
  21. ๐Ÿ“Š Monitor queue depth and, especially, oldest-message age.
  22. ๐Ÿ“ Use structured logging without dumping sensitive payloads.
  23. ๐Ÿงช Test process termination and ambiguous network failures.
  24. ๐Ÿ’ฃ Inject failures between architectural steps.
  25. ๐Ÿ”‹ Design synchronization for mobile battery and lifecycle constraints.

๐ŸŽฏ Conclusion

Offline reliability is not simply:

    Detect no internet
          +
    Try again later

The real problem is maintaining a durable relationship between what the user did locally and what must eventually happen remotely.

Without an explicit architecture, seemingly simple operations create dangerous failure windows:

    Save locally
         โ”‚
         โ–ผ
    Send remotely

What happens between those two steps determines whether the system is reliable.

The Outbox Pattern changes the architecture:

                  Local Transaction
                โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
                โ–ผ                 โ–ผ
          Business State      Delivery Intent
                โ”‚                 โ”‚
                โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                         โ”‚
                       COMMIT
                         โ”‚
                         โ–ผ
                 Durable Outbox
                         โ”‚
                         โ–ผ
                 Outbox Processor
                         โ”‚
                  Retry + Backoff
                         โ”‚
                         โ–ผ
                     REST API
                         โ”‚
                         โ–ผ
                    Idempotency

Now application termination doesn't automatically lose pending work.

Network outages don't require users to repeat actions manually.

Retries don't have to create duplicate business effects.

Application restarts don't erase synchronization intent.

And synchronization becomes something we can observe, test, reason about, and recover.

The most important concept is that the Outbox does not magically create exactly-once distributed transactions.

Instead, it gives us a practical reliability model:

    Atomic local persistence
              +
    Durable delivery intent
              +
    At-least-once delivery
              +
    Server-side idempotency
              +
    Explicit conflict handling
              =
    Reliable offline synchronization

For a simple application that always requires connectivity, this architecture may be unnecessary.

But once a .NET MAUI application must survive unreliable networks, process termination, offline operation, delayed synchronization, and ambiguous HTTP failures, an Outbox can become one of the most valuable patterns in the mobile architecture. ๐Ÿ“ฌ๐Ÿš€


๐Ÿ”— References


Was this useful?

Comments (0)

Leave a comment

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