Eventual Consistency Explained for .NET Developers

distributed-systemsdotnet
Eventual Consistency Explained for .NET Developers

Eventual consistency means replicas converge when updates stop and the system can propagate and reconcile those updates. In .NET services, this often means committing a change locally, publishing an event through an outbox, and updating another service's data later. The delay has no universal upper bound, so applications need safe intermediate states and observable recovery.

What Is Eventual Consistency?

A committed order can exist in the order database before its search projection or inventory workflow has caught up. During that interval, different parts of the application expose different states. That can be acceptable if the business and the API define what those states mean.

Eventual consistency does not itself guarantee durability or preserve every concurrent update. A last-write-wins conflict rule, for example, deliberately selects one value over another. You need a delivery mechanism and conflict policy that satisfy the data's requirements.

There is also no promise that "eventually" means 100 milliseconds. Broker outages, poison messages, and consumer backlogs can extend the delay until recovery or manual intervention occurs. Measure that interval rather than describing every inconsistency window as brief.

Strong, Causal, and Eventual Consistency

Linearizable operations behave as if there were one copy of the data, respecting the order of already-completed operations. Causal consistency preserves cause-and-effect relationships between operations. Eventual consistency allows temporarily different views while requiring convergence under the model's assumptions.

A single relational database does not automatically make every application read linearizable. Isolation level, concurrent transactions, caches, and read replicas affect what a query sees. Use database constraints and an appropriate transaction boundary for invariants that must hold atomically.

Splitting services often introduces asynchronous propagation, but it is a design choice rather than a requirement to weaken every operation. The CAP theorem explains the limit when partitioned nodes must both remain available and provide linearizable operations. Outside that situation, latency, availability, and operational complexity still influence the design.

Publish the Committed Change Reliably

An order service must not commit an order and then hope that publishing OrderPlaced succeeds. A crash between those operations leaves permanent missing information downstream.

Use the transactional outbox to persist the business change and outgoing message together. The complete EF Core example uses a scoped MassTransit bus outbox: capture the publish first, then call SaveChangesAsync once for both. A background dispatcher sends committed messages afterward.

Include everything the consumer needs in the contract. If inventory needs product IDs and quantities, an event containing only an order ID and total amount cannot reserve stock without another lookup. That lookup introduces its own availability and versioning requirements.

Sequence showing the order service saving an order and returning 201 immediately, with an inconsistency window before the broker delivers the event and the inventory service converges

The 201 Created response means the order resource was created. It should expose a pending status if inventory or payment still needs to complete. It does not promise that every consumer has applied the event.

Delivery retries can create duplicates. Use an idempotent consumer, with its deduplication record and business changes committed together. For a multi-step order process, a saga defines the pending states and compensation.

Apply Snapshot Events Atomically

A projection often needs only the newest complete state of an entity. For those snapshot events, an authoritative per-entity version lets the consumer ignore duplicates and older versions. The comparison and update must be atomic, including the first insert.

A C# read-compare-save sequence is insufficient by itself. Two consumers can both read version 1, then save versions 3 and 2 in that order, leaving the projection stale. A PostgreSQL conditional upsert moves that check into the write.

Create a runnable .NET 10 example using Npgsql:

dotnet new console -n ProjectionDemo -f net10.0
cd ProjectionDemo
dotnet add package Npgsql --version 10.0.0
docker run --rm --name projection-postgres -e POSTGRES_PASSWORD=postgres -p 5432:5432 -d postgres:18

After PostgreSQL is ready, replace Program.cs and run dotnet run:

using Npgsql;

await using var dataSource = NpgsqlDataSource.Create(
    "Host=localhost;Database=postgres;Username=postgres;Password=postgres");

await using (var create = dataSource.CreateCommand("""
    CREATE TABLE IF NOT EXISTS customer_profiles (
        customer_id UUID PRIMARY KEY,
        name TEXT NOT NULL,
        email TEXT NOT NULL,
        version BIGINT NOT NULL CHECK (version > 0)
    )
    """))
{
    await create.ExecuteNonQueryAsync();
}

var customerId = Guid.NewGuid();
await ApplyAsync(new(customerId, 3, "Alex", "[email protected]"));
await ApplyAsync(new(customerId, 2, "Old name", "[email protected]"));
await ApplyAsync(new(customerId, 3, "Alex", "[email protected]"));

await using var read = dataSource.CreateCommand("""
    SELECT name, version FROM customer_profiles WHERE customer_id = $1
    """);
read.Parameters.AddWithValue(customerId);
await using var reader = await read.ExecuteReaderAsync();
await reader.ReadAsync();
Console.WriteLine($"{reader.GetString(0)}: version {reader.GetInt64(1)}");

async Task ApplyAsync(CustomerSnapshot message)
{
    await using var command = dataSource.CreateCommand("""
        INSERT INTO customer_profiles (customer_id, name, email, version)
        VALUES ($1, $2, $3, $4)
        ON CONFLICT (customer_id) DO UPDATE
        SET name = EXCLUDED.name,
            email = EXCLUDED.email,
            version = EXCLUDED.version
        WHERE customer_profiles.version < EXCLUDED.version
        """);
    command.Parameters.AddWithValue(message.CustomerId);
    command.Parameters.AddWithValue(message.Name);
    command.Parameters.AddWithValue(message.Email);
    command.Parameters.AddWithValue(message.Version);
    await command.ExecuteNonQueryAsync();
}

public record CustomerSnapshot(
    Guid CustomerId, long Version, string Name, string Email);

The output is:

Alex: version 3

The PostgreSQL ON CONFLICT documentation defines the atomic upsert behavior. Concurrent updates to the same customer are serialized by the row write, and the version condition prevents an older snapshot replacing a newer one. In a broker consumer, acknowledge the message only after this command succeeds and pass the consumer cancellation token to database operations.

Assign versions at the authoritative writer as part of its transaction. Two competing writers cannot independently assign version 3 with different payloads and expect the consumer to resolve that conflict. Use a single authority per entity or a conflict model that handles multiple writers.

This technique does not apply to arbitrary deltas. If version 2 means "add 10" and version 3 means "add 20," discarding version 2 loses a required operation. Use sequence and gap handling for streams where every event must be applied.

Deletion also needs a versioned representation. Keep a tombstone or deletion version long enough to prevent an old snapshot from recreating a deleted profile. Retention depends on your replay and redelivery window.

Handle Reads After a Write

A user can create a resource and immediately receive a 404 from a lagging projection. Several approaches address different parts of that experience:

  • Return the committed representation. The create response can include the ID, current version, and pending workflow status from the write store.
  • Route follow-up reads to the authority. This gives read-your-writes behavior when that read path provides the required consistency. Moving back to a replica should depend on its having caught up, rather than only a guessed delay.
  • Expose projection progress. Let a client request at least version 7, and return a pending response or wait within a deadline until the projection reaches it.
  • Use optimistic UI carefully. Show submitted values while the request is pending, then reconcile with the server. It improves the interaction but does not change the database guarantee.

Polling a 404 only makes sense when the client knows the resource was committed and the endpoint documents projection lag. Bound the retry duration, respect cancellation, and do not retry authorization failures as if they were stale data. For longer workflows, an explicit status endpoint is easier to operate than repeated reads that look like missing resources.

Measure Convergence and Recovery

Track the age of the oldest pending outbox message, queue backlog, consumer failures, and projection version lag. Observe both producer delivery and consumer application: a cleared outbox only proves dispatch progressed.

Subtracting an event timestamp from consumer wall-clock time gives an approximate age. Clock skew can distort it, including producing negative values. Use synchronized clocks, monitor skew, and combine age metrics with queue and version measurements.

Define recovery for poison messages and unavailable consumers. If a snapshot projection falls behind, rebuilding it from authoritative state may be safe. A ledger or other delta-based stream usually needs complete replay and reconciliation instead.

Summary

Keep atomic invariants inside a boundary that can enforce them. Across boundaries, persist outgoing events reliably, handle duplicates, and choose a conflict policy appropriate to snapshots or deltas.

Expose pending states and measure how far consumers lag. Convergence depends on the delivery and recovery mechanisms you operate, not on calling the system eventually consistent.

Frequently Asked Questions

What is eventual consistency in simple terms?

Replicas can temporarily disagree, but converge once updates stop and propagation and reconciliation complete. The model does not provide a fixed time limit or a durability guarantee on its own.

Is eventual consistency the same as losing data?

No, but durability is a separate guarantee. An outbox protects the intent to publish, and consumers need retry, deduplication, and a conflict policy. Some conflict policies intentionally discard competing values.

When should I avoid eventual consistency?

Avoid it when a business rule must be enforced atomically, such as preventing a double withdrawal, enforcing a unique username, or checking a credit limit. Those operations belong inside one service and one database transaction with strong consistency.

How long does eventual consistency take in practice?

There is no universal duration or upper bound. Polling, message transport, processing, retries, and outages determine the lag. Measure backlog and event age against the requirements of each workflow.

What is read-your-writes consistency?

It is a guarantee that a user always sees the effect of their own writes, even if other replicas are still catching up. A common implementation is serving the read that immediately follows a write from the primary or the write model instead of an eventually consistent read model.

  • Amazon SQS With .NET: A Practical Guide

    Amazon SQS removes the broker from your operations burden, but reliable consumers still depend on visibility timeouts, explicit deletes, long polling, and a dead letter policy. This guide builds that baseline in .NET with the AWS SDK, FIFO queues, batch operations, and MassTransit.

  • Azure Service Bus Topics, Subscription Filters, and Dead Letter Queues in .NET

    Go beyond basic queues in Azure Service Bus: publish to topics, route messages with SQL and correlation filters on the broker, and build a dead letter queue handling strategy in .NET.

  • Backpressure in .NET: Handling Producers That Outrun Consumers

    Backpressure slows producers when consumers cannot keep up. Use bounded .NET channels to wait for capacity, drop replaceable updates, or reject excess requests with 503. The examples distinguish admission to memory from durable processing and show how to size a queue for a burst.

Browse all Distributed Systems guides →
Loading comments...

Whenever you're ready, there are 5 ways I can help you:

  1. Pragmatic Self-Hosting: Learn to deploy and run your apps on a VPS with Dokploy and Tailscale. Keep administration private, automate releases, and practice restoring backups, with source code and a runbook you can reuse.
  2. Pragmatic Clean Architecture: Join 5,000+ students in this comprehensive course that will teach you the system I use to ship production-ready applications using Clean Architecture. Learn how to apply the best practices of modern software architecture.
  3. Modular Monolith Architecture: Join 2,800+ engineers in this in-depth course that will transform the way you build modern systems. You will learn the best practices for applying the Modular Monolith architecture in a real-world scenario.
  4. Pragmatic REST APIs: Join 1,900+ students in this course that will teach you how to build production-ready REST APIs using the latest ASP.NET Core features and best practices. It includes a fully functional UI application that we'll integrate with the REST API.
  5. Patreon Community: Join a community of 5,000+ engineers and software architects. You will also unlock access to the source code I use in my YouTube videos, early access to future videos, and exclusive discounts for my courses.

The .NET Weekly

One practical .NET tip, every Saturday.

Join 66,000+ engineers who read it. Free, and you can unsubscribe any time.