Multi-Region Active-Active on Azure for a .NET API: What Happens to the Write You Acknowledged

Putting an API in two Azure regions behind Front Door takes an afternoon. Both regions answer, the health probe turns green, and the architecture diagram gains a second column. None of that is the difficult part. The difficult part is a question about data: two people book the same appointment slot through two regions inside the replication window, both are told “booked”, and one of them is wrong. Which one, who decides, and who tells them?

This article works through that question for a .NET booking API on Azure. It compares four ways of placing writes on the same simulated outage, sets out what Azure Cosmos DB does with a conflict, shows what the API has to do so that a retry can land in the other region, and ends with the global tier in Terraform. The code targets .NET 10 and runs without an Azure subscription.

⚠️
CLINICAL SAFETY

The example is appointment booking because it makes the failure concrete. A booking that is acknowledged and later overwritten is a patient who travels to an appointment that is no longer theirs. Where the records are prescriptions, orders or results, the same failure is a clinical safety hazard and belongs in the hazard log before the design is approved, with the conflict rule as its named mitigation.

What active-active has to mean

The term is used loosely, so it helps to pin it down as three requirements that can be tested.

  • Both regions serve reads and writes all the time. A region that only receives traffic during a failover is a standby, whatever the diagram calls it.
  • Losing either region needs no operator. The remaining region carries the full load, which means each region is sized for all of it.
  • No client is tied to a region. Any request, including a retry of a request that was already sent, can arrive at either one.

The first requirement forces the data store to accept writes in two places. The replication between those places is asynchronous, so for a short interval each region holds writes the other has not seen. Two writes to the same record inside that interval are a conflict. With real traffic this is a certainty to be designed for and not an edge case to be discounted.

Azure Front Door probes a readiness endpoint and routes to a booking API in North Europe and West Europe with equal priority and weight. Each API writes to its regional Cosmos DB endpoint, the two replicate asynchronously, and conflicts go to the conflicts feed. Three decisions are marked: routing, retries and conflicts.
The request path and the three places a design decision sits: what the probe asks, where a retry lands, and who resolves a conflict.
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#FFF3E0','primaryTextColor':'#232F3E','primaryBorderColor':'#FF9900','lineColor':'#232F3E','clusterBkg':'#F8FAFC','clusterBorder':'#CBD5E1','edgeLabelBackground':'#FFFFFF','nodeTextColor':'#232F3E'}}}%%
flowchart TD
    C["Client
sends an Idempotency-Key"] --> AFD["Azure Front Door
probes /ready in each region"] AFD --> N["Booking API
North Europe"] AFD --> W["Booking API
West Europe"] N --> CN[("Cosmos DB
North Europe")] W --> CW[("Cosmos DB
West Europe")] CN -. "asynchronous replication" .-> CW CW -. "asynchronous replication" .-> CN CN --> F["Conflicts feed"] CW --> F F --> R["Conflict worker
applies the rule, records a compensation"] classDef compute fill:#FFF3E0,stroke:#FF9900,stroke-width:2px,color:#232F3E classDef db fill:#EBF8FF,stroke:#0073BB,stroke-width:2px,color:#004C74 classDef edge fill:#F3E8FF,stroke:#7C3AED,stroke-width:2px,color:#4C1D95 classDef alert fill:#FEF2F2,stroke:#D13212,stroke-width:2px,color:#991B1B class C,N,W,R compute class CN,CW db class AFD edge class F alert

Four ways to place the writes

Every multi-region design for a transactional API is one of these four, or a mixture of them by record type.

StrategyWhere writes are acceptedDuring a region outageConflicts
Multi-write, last writer winsBoth regionsNothing is refusedResolved by the database. The losing write disappears without a record
Multi-write, application resolvesBoth regionsNothing is refusedSent to the application, which applies its own rule and compensates the loser
One writer per recordThe record’s home regionWrites to records homed in the lost region are refusedNone
Active-passiveThe primary regionWrites are refused until the standby is promotedNone, but writes not yet replicated at promotion are stranded

The first two are active-active for every record. The third is active-active for the service and single-writer for each record. The fourth is the baseline the others have to beat.

One outage, four outcomes

Arguments about these strategies tend to stay abstract, so the sample runs all four on one workload. Two thousand booking attempts arrive, one per tick, each at a random region for a random slot out of two thousand. An accepted write reaches the other region twenty ticks later. One region is down from tick 800 to tick 920, and in the active-passive case the standby is promoted thirty ticks into the outage. The generator is seeded, so every run produces the same numbers.

namespace ActiveActive.Core;

public enum Strategy
{
    /// <summary>Both regions accept writes. A conflict keeps the later write and says nothing.</summary>
    MultiWriteLastWriterWins,

    /// <summary>Both regions accept writes. A conflict keeps the earlier write and records a compensation for the loser.</summary>
    MultiWriteFirstWriterWins,

    /// <summary>Each slot has a home region. No conflicts, and no writes to a slot while its home is down.</summary>
    SingleWriterPerSlot,

    /// <summary>One primary. The standby is promoted after a delay and has not seen the primary's last unreplicated writes.</summary>
    ActivePassive,
}

public sealed record SimulationResult(
    int Accepted, int RefusedSlotTaken, int RefusedUnavailable, int FailedOver, int Conflicts,
    int SilentlyLost, int Compensated, int AcknowledgedThenNotHeld, int FinalBookings, bool RegionsAgree);

/// <summary>
/// A booking service in two regions on a fixed workload. One attempt arrives per tick at a random region for a
/// random slot; an accepted write reaches the other region <see cref="Lag"/> ticks later; one region is down
/// for a window. The Python sample runs the same model, and the two must print the same numbers.
/// </summary>
public static class BookingSimulation
{
    public const int Slots = 2000;
    public const int Attempts = 2000;
    public const int Lag = 20;
    public const int OutageStart = 800;
    public const int OutageEnd = 920;
    public const int FailoverDelay = 30;

    private readonly record struct Write(int Patient, int Tick, int Region);

    private sealed record Message(int Due, int Sender, int Slot, Write Write);

    public static SimulationResult Run(Strategy strategy, int failedRegion, uint seed = 20261025)
    {
        uint state = seed;
        int Next(int bound)
        {
            state = unchecked(state * 1664525 + 1013904223);
            return (int)((state >> 8) % (uint)bound);
        }

        Dictionary<int, Write>[] view = [[], []];
        List<Message> outbox = [];
        HashSet<int> acknowledged = [];
        int accepted = 0, slotTaken = 0, unavailable = 0, failedOver = 0, conflicts = 0, lost = 0, compensated = 0;
        int primary = 0;
        bool promoted = false;

        bool Up(int region, int tick) => !(region == failedRegion && tick >= OutageStart && tick < OutageEnd);

        void Book(int region, int slot, int patient, int tick)
        {
            if (view[region].ContainsKey(slot))
            {
                slotTaken++;
                return;
            }

            Write write = new(patient, tick, region);
            view[region][slot] = write;
            outbox.Add(new Message(tick + Lag, region, slot, write));
            acknowledged.Add(patient);
            accepted++;
        }

        for (int tick = 0; tick < Attempts || outbox.Count > 0; tick++)
        {
            // Replication: a message is delivered once it is due and both ends are up.
            List<Message> waiting = [];
            foreach (Message message in outbox)
            {
                int receiver = 1 - message.Sender;
                if (message.Due > tick || !Up(message.Sender, tick) || !Up(receiver, tick))
                {
                    waiting.Add(message);
                    continue;
                }

                if (!view[receiver].TryGetValue(message.Slot, out Write local) || local == message.Write)
                {
                    view[receiver][message.Slot] = message.Write;
                    continue;
                }

                int order = (message.Write.Tick, message.Write.Region).CompareTo((local.Tick, local.Region));
                bool incomingWins = strategy == Strategy.MultiWriteLastWriterWins ? order > 0 : order < 0;
                if (incomingWins)
                {
                    // Each conflict is seen from both sides; count it where the local write loses.
                    conflicts++;
                    if (strategy == Strategy.MultiWriteLastWriterWins) { lost++; } else { compensated++; }
                    view[receiver][message.Slot] = message.Write;
                }
            }

            outbox = waiting;

            if (strategy == Strategy.ActivePassive)
            {
                if (!promoted && failedRegion == primary && tick == OutageStart + FailoverDelay)
                {
                    // Promote the standby. Writes the old primary had not shipped are gone.
                    lost += outbox.Count;
                    outbox.Clear();
                    primary = 1 - primary;
                    promoted = true;
                }

                if (promoted && tick == OutageEnd)
                {
                    view[1 - primary] = new Dictionary<int, Write>(view[primary]); // the old primary rejoins as a copy
                }
            }

            if (tick >= Attempts)
            {
                continue;
            }

            int arrival = Next(2), slot = Next(Slots), patient = tick;
            switch (strategy)
            {
                case Strategy.MultiWriteLastWriterWins or Strategy.MultiWriteFirstWriterWins:
                    int target = arrival;
                    if (!Up(target, tick))
                    {
                        target = 1 - arrival;
                        failedOver++;
                    }

                    Book(target, slot, patient, tick);
                    break;
                case Strategy.SingleWriterPerSlot:
                    int home = slot % 2;
                    if (Up(home, tick)) { Book(home, slot, patient, tick); } else { unavailable++; }
                    break;
                default:
                    if (Up(primary, tick)) { Book(primary, slot, patient, tick); } else { unavailable++; }
                    break;
            }
        }

        // Counted a second way: every patient who was told "booked" and does not hold the slot at the end.
        HashSet<int> holders = [.. view[0].Values.Select(w => w.Patient)];
        bool agree = view[0].Count == view[1].Count && view[0].All(pair => view[1].TryGetValue(pair.Key, out Write other) && other == pair.Value);
        return new SimulationResult(accepted, slotTaken, unavailable, failedOver, conflicts, lost, compensated,
            acknowledged.Count(patient => !holders.Contains(patient)), view[0].Count, agree);
    }
}

Two counters matter. Refused is a request that was turned away because the region that had to take it was down: the caller saw an error. Taken back is a booking that was acknowledged and that the patient does not hold at the end: the caller saw success. The self-test counts the second one twice, once as conflicts are resolved and once by comparing every acknowledged patient against the final state, and the two counts agree in all eight runs.

Results of a simulated region outage for four write strategies. Multi-write with last writer wins refuses nothing and takes back 9 acknowledged bookings with no record. Multi-write with application resolution takes back the same 9 with a compensation each. One writer per slot refuses 47 requests and loses none. Active-passive refuses 30 and loses 16 that were not replicated at promotion.
The north region fails. A model on a fixed seed: it shows the shape of each trade, not Azure timings.
Region lostStrategyAcceptedRefused, region downTaken back, no recordTaken back, compensated
North (the primary)Multi-write, last writer wins1,265090
Multi-write, application resolves1,265009
One writer per slot1,2364700
Active-passive1,25930160
West (the standby)Multi-write, last writer wins1,265090
Multi-write, application resolves1,265009
One writer per slot1,2357300
Active-passive1,256000

Last writer wins has the best availability figure and the worst failure. Nine bookings out of 1,265 is 0.7 per cent, small enough to pass any load test and to go unnoticed for months, and each one is a person who was told yes. The count is the same whichever region fails, because these conflicts come from ordinary concurrent traffic and not from the outage.

Application resolution has exactly the same nine conflicts. The difference is that each one produces a record, and a record can drive a message to the patient and an offer of another slot. The data outcome is identical. The operational outcome is not.

One writer per slot removes conflicts by construction and pays for it in refusals: every request for a slot homed in the lost region fails until the region returns. A refusal is visible. The caller sees an error, and can be shown another slot or asked to retry.

Active-passive is asymmetric. Losing the standby costs nothing. Losing the primary refuses thirty requests while the standby is promoted, and strands sixteen writes that the primary had acknowledged and not yet shipped.

📝
WHAT THE MODEL LEAVES OUT

A tick is not a second, and the lag and promotion delay are parameters, not measurements. For Azure Cosmos DB, Microsoft documents a recovery point of under 15 minutes for session, consistent prefix and eventual consistency on a region outage, and says service-managed failover of a single-write account can take an hour or more. Writes stranded in a failed region are not always gone: when the region returns they are offered through the conflicts feed, which still needs somebody to read it.

What Cosmos DB does with a conflict

With multiple write regions enabled, Cosmos DB detects three kinds of conflict: two inserts with the same unique index, usually the id, in different regions; two replaces of the same item; and a delete in one region against an update in another. Each container carries one resolution policy.

%%{init: {'theme':'base','themeVariables':{'primaryColor':'#FFF3E0','primaryTextColor':'#232F3E','primaryBorderColor':'#FF9900','lineColor':'#232F3E','signalColor':'#0073BB','signalTextColor':'#0F172A','actorBkg':'#FFF3E0','actorBorder':'#FF9900','actorTextColor':'#232F3E','noteBkgColor':'#FFF8E7','noteBorderColor':'#FF9900','noteTextColor':'#232F3E'}}}%%
sequenceDiagram
    participant N as API North
    participant W as API West
    participant DB as Cosmos DB
    N->>DB: Create booking for slot 09.00, patient A
    DB-->>N: 201 Created in North Europe
    W->>DB: Create booking for slot 09.00, patient B
    DB-->>W: 201 Created in West Europe
    Note over DB: Replication finds two items with the same id
    alt Last writer wins
        DB->>DB: Keep the higher _ts and discard the other
    else Custom policy with no merge procedure
        DB-->>W: Both versions appear in the conflicts feed
    end
  • Last writer wins is the default. The item with the higher value at the conflict resolution path wins, and the default path is the system timestamp _ts. The NoSQL API allows another numeric property. A delete always wins against an insert or a replace.
  • A custom policy calls a merge stored procedure that you register, inside a transaction, when a conflict is found. It is available only for the NoSQL API.
  • A custom policy with no procedure, or one whose procedure throws, writes the conflict to the conflicts feed. Nothing is discarded, and the application reads the feed and decides.
  • The policy is fixed when the container is created. A custom policy cannot be put on an existing container, so this decision belongs in the first design review and not the first incident review.
  • Strong consistency is not available on an account with multiple write regions.

There is a modelling consequence that is easy to miss. Cosmos DB detects conflicts on items, not on business rules. If each booking is a new document with its own generated id, two bookings for one slot are two different items, no conflict is raised, and both survive. For the database to see the double booking, the rule has to be expressed as identity: the booking for a slot uses the slot identifier as its id, in a container partitioned by slot. Then two regional inserts collide, and the policy applies.

A retry can land in the other region

The third requirement, that no client is tied to a region, has a consequence for every command the API accepts. A client that times out retries. Front Door itself makes a best-effort attempt against another origin when it cannot open a connection to the one it chose. Either way the second attempt may be served by a region that has not yet seen the first. A POST that creates a booking has to be safe to receive twice.

The contract is an Idempotency-Key header that the client generates once per intent and repeats on every retry. The API runs the command once for each key and returns the stored answer afterwards.

using System.Collections.Concurrent;

namespace ActiveActive.Core;

/// <summary>The stored outcome of a command, returned again when the same command is retried.</summary>
public sealed record CommandOutcome(int StatusCode, string Body, string Region);

/// <summary>
/// Makes a retried request safe. A client that times out against one region and retries against the other
/// sends the same idempotency key; the command runs once and both attempts get the same answer.
/// In production the store is the regional database (the key is written in the same transaction as the
/// booking), not process memory.
/// </summary>
public sealed class IdempotentCommands
{
    private readonly ConcurrentDictionary<string, Lazy<CommandOutcome>> _outcomes = new(StringComparer.Ordinal);

    /// <summary>Runs <paramref name="command"/> at most once per key, even under concurrent calls.</summary>
    public CommandOutcome Execute(string idempotencyKey, Func<CommandOutcome> command)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(idempotencyKey);
        return _outcomes.GetOrAdd(idempotencyKey, _ => new Lazy<CommandOutcome>(command, LazyThreadSafetyMode.ExecutionAndPublication)).Value;
    }

    /// <summary>Applies outcomes replicated from another region, so a retry that lands here is recognised.</summary>
    public void Import(string idempotencyKey, CommandOutcome outcome) =>
        _outcomes.TryAdd(idempotencyKey, new Lazy<CommandOutcome>(() => outcome));
}

The Lazy matters: GetOrAdd alone may invoke its factory more than once under contention, and the self-test sends 64 concurrent retries of one key to confirm the command runs once. In a deployment the store is the database and not process memory. The key is written with the booking in one transactional batch in the same partition, so it replicates with the booking and a retry in the other region finds it. Inside the replication window it will not find it, the command runs a second time, and the result is an insert conflict on the same id. That is acceptable only because the conflict path already exists. Idempotency narrows the window. It does not remove the need for a conflict rule.

What the health probe should ask

Front Door decides where to send a request in a fixed order: discard origins that are disabled or failing their probe, keep those with the best priority, keep those within the latency sensitivity of the fastest, then spread by weight. A few details of that sequence shape the design.

  • Only 200 OK counts as healthy. A 204 or a redirect from the probe path is a failure.
  • Health is a vote over recent samples. With a 30-second interval, a sample size of four and three successes required, an origin leaves rotation once two of its last four probes have failed, which is about a minute after it starts failing.
  • If every origin fails its probe, Front Door sends traffic to all of them. A probe that fails in both regions at once therefore does not take the service down, and it does not protect it either.
  • Latency sensitivity defaults to zero, which sends each request to the fastest origin and makes the weights irrelevant unless latencies are equal. Two regions that are meant to share load need a non-zero value.
  • Every edge location probes. At a 30-second interval Microsoft’s estimate is two requests a minute for each edge location, so the probe endpoint has to be cheap.

That leads to the rule for what the readiness endpoint reports. A region takes itself out for a problem that is its own: it is draining, it cannot reach its database endpoint, or its database is refusing writes. It does not take itself out for a problem both regions share. Replication lag is the obvious one. Both regions observe the same lag, both would fail the probe, and the outcome would be decided by the all-unhealthy fallback instead of by design. Lag is reported and alerted on.

namespace ActiveActive.Core;

public sealed record RegionState(bool DatabaseReachable, bool AcceptingWrites, TimeSpan ReplicationLag, bool Draining);

public sealed record ReadinessVerdict(bool Ready, string Reason);

/// <summary>
/// What the global load balancer's health probe is told. The rule that matters: a region does not take
/// itself out of rotation for a problem every region shares. Replication lag is reported and alerted on,
/// and is not a reason to fail the probe, because both regions see the same lag and would both leave.
/// </summary>
public static class Readiness
{
    public static ReadinessVerdict Evaluate(RegionState state)
    {
        if (state.Draining)
        {
            return new ReadinessVerdict(false, "draining for a deployment or a drill");
        }

        if (!state.DatabaseReachable)
        {
            return new ReadinessVerdict(false, "regional database endpoint unreachable");
        }

        if (!state.AcceptingWrites)
        {
            return new ReadinessVerdict(false, "regional database is not accepting writes");
        }

        return new ReadinessVerdict(true, state.ReplicationLag > TimeSpan.FromSeconds(30)
            ? "ready; replication lag is high and should alert"
            : "ready");
    }
}

Liveness stays separate. The sample API exposes /healthz for the container platform, which answers while the process is up, and /ready for Front Door. Wiring the global probe to a liveness endpoint is the commonest mistake in these designs: the region keeps receiving traffic it cannot serve.

The global tier in Terraform

The regional tier, meaning the container platform and the API, is deployed once per region by its own module; the earlier article on Container Apps and AKS for a regulated workload covers that choice. What makes the system active-active is the global tier above it, and it is short.

# The global tier of an active-active API on Azure: a multi-region-write Cosmos DB account, and Front Door
# probing and balancing the regional APIs. The regional tier (the container platform and the API itself)
# is deployed once per region by its own module.

locals {
  tags = {
    workload   = var.name
    managed-by = "terraform"
  }
}

resource "azurerm_cosmosdb_account" "this" {
  name                = "cosmos-${var.name}"
  resource_group_name = var.resource_group_name
  location            = var.regions[0]
  offer_type          = "Standard"
  kind                = "GlobalDocumentDB"

  # Every region accepts writes. Strong consistency is not available in this mode.
  multiple_write_locations_enabled = true
  automatic_failover_enabled       = true

  local_authentication_enabled = false
  public_network_access_enabled = false

  consistency_policy {
    consistency_level = "Session"
  }

  dynamic "geo_location" {
    for_each = var.regions

    content {
      location          = geo_location.value
      failover_priority = geo_location.key
      zone_redundant    = true
    }
  }

  tags = local.tags
}

resource "azurerm_cosmosdb_sql_database" "this" {
  name                = "bookings"
  resource_group_name = var.resource_group_name
  account_name        = azurerm_cosmosdb_account.this.name
}

resource "azurerm_cosmosdb_sql_container" "bookings" {
  name                = "bookings"
  resource_group_name = var.resource_group_name
  account_name        = azurerm_cosmosdb_account.this.name
  database_name       = azurerm_cosmosdb_sql_database.this.name
  partition_key_paths = ["/slotId"]

  # Custom mode with no stored procedure sends every conflict to the conflicts feed, where the application
  # applies its own rule and records a compensation. The default, last writer wins, resolves silently.
  conflict_resolution_policy {
    mode = "Custom"
  }
}

resource "azurerm_cdn_frontdoor_profile" "this" {
  name                = "afd-${var.name}"
  resource_group_name = var.resource_group_name
  sku_name            = "Premium_AzureFrontDoor"
  tags                = local.tags
}

resource "azurerm_cdn_frontdoor_endpoint" "this" {
  name                     = "api-${var.name}"
  cdn_frontdoor_profile_id = azurerm_cdn_frontdoor_profile.this.id
  tags                     = local.tags
}

resource "azurerm_cdn_frontdoor_origin_group" "api" {
  name                     = "regional-apis"
  cdn_frontdoor_profile_id = azurerm_cdn_frontdoor_profile.this.id
  session_affinity_enabled = false

  # Probe readiness, not liveness: a region whose database is unreachable must leave rotation.
  health_probe {
    protocol            = "Https"
    request_type        = "GET"
    path                = "/ready"
    interval_in_seconds = 30
  }

  load_balancing {
    sample_size                        = 4
    successful_samples_required        = 3
    additional_latency_in_milliseconds = 50
  }
}

resource "azurerm_cdn_frontdoor_origin" "api" {
  for_each = toset(var.regions)

  name                           = "api-${each.key}"
  cdn_frontdoor_origin_group_id  = azurerm_cdn_frontdoor_origin_group.api.id
  enabled                        = true
  host_name                      = var.origin_host_names[each.key]
  origin_host_header             = var.origin_host_names[each.key]
  certificate_name_check_enabled = true

  # Equal priority and weight is what makes this active-active. A higher priority number would make
  # that region a standby.
  priority = 1
  weight   = 500
}

resource "azurerm_cdn_frontdoor_route" "api" {
  name                          = "api"
  cdn_frontdoor_endpoint_id     = azurerm_cdn_frontdoor_endpoint.this.id
  cdn_frontdoor_origin_group_id = azurerm_cdn_frontdoor_origin_group.api.id
  cdn_frontdoor_origin_ids      = [for origin in azurerm_cdn_frontdoor_origin.api : origin.id]

  patterns_to_match      = ["/*"]
  supported_protocols    = ["Https"]
  forwarding_protocol    = "HttpsOnly"
  https_redirect_enabled = false
  link_to_default_domain = true
}

Four settings carry the design. multiple_write_locations_enabled makes every region a write region. The container’s conflict resolution mode is Custom with no procedure, so conflicts go to the feed. Both origins have the same priority and weight, which is the difference between this and a standby. The probe path is /ready. Key-based authentication and public network access on the database are off, so the APIs reach it with managed identities over private endpoints that the regional module creates.

Seven tests assert those properties and two input validations against a mocked AzureRM 5.2.0 provider, with no credentials. All seven pass on Terraform 1.16.3 and on OpenTofu 1.12.6. One of them runs after a mocked apply instead of at plan, because Terraform could not read the set of database locations at plan while each still carried an unknown computed id; the approach is described in Testing Infrastructure Code.

The drill

A failover that has not been rehearsed is a hypothesis. The sample includes a drill that starts two instances of the API as “north” and “west” and walks through what a client behind the load balancer experiences.

StepExpectedResult
Book through north201, decided in northPass
Repeat the same request with the same keyThe same 201 and body, not a conflictPass
A different patient asks for the same slot409Pass
A booking with no idempotency key400Pass
Drain north/ready returns 503 with the reasonPass
Check north’s liveness/healthz still returns 200Pass
Probe west and book through it200, then 201 decided in westPass

Every response carries an x-region header, which is how an operator watching a real drill sees traffic move. The API logs the slot and the outcome and never the patient reference; the pipeline searches both instances’ logs for one and finds none. Each instance keeps its own memory, so this drill exercises the request contract and not replication.

What it costs

The bill for active-active is structural, and it is worth setting out before the design is socialised. Prices vary by region and agreement, so the table gives multipliers against a single-region deployment and not currency.

ItemActive-passiveActive-active
Database throughputProvisioned RU/s billed in each regionBilled in each region, at the multi-region write rate
Zone redundancy1.25 times the RU price in each region that uses itThe same
ComputeFull size in the primary; the standby can run smaller and scale on failoverFull size in both regions, each able to carry all traffic
Global routingFront Door profile, requests and data transferThe same
EngineeringA failover runbook and a drillA conflict rule, a worker that applies it, a compensation process, and a drill

The last row is the one that gets left out of business cases. A conflicts feed that nobody reads is last writer wins with extra steps.

Decision framework

The choice turns on the data and not on the platform. Three questions, asked in order for each record type, settle it.

QuestionIf the answer isThen
Can two callers ever write the same record?No: append-only events, or data only its owner editsMulti-region writes are cheap. Last writer wins is safe because it never fires
If they can, is a lost acknowledged write acceptable or mergeable?Yes: counters, preferences, last-seen markersMulti-region writes with last writer wins or a merge procedure
No, and a short refusal is acceptableOne writer per record with a home region, or a single write region with fast failover
Must an acknowledged write survive the loss of a region?Yes, with no exceptionsStrong consistency with one write region. That is not active-active for writes, and the latency cost is real

Most systems have records of more than one kind. A booking service can take reads and audit events in both regions, home each clinic’s slots in the region nearest to it, and keep reference data in one write region. Mixing strategies by record type is usually the right answer, and it is cheaper to operate than a single strategy stretched over data it does not suit.

Key takeaways

  • Active-active is a decision about writes. Routing two regions is configuration; deciding what happens to two writes to one record is design, and it is fixed when the container is created.
  • Last writer wins fails rarely and silently. In the model it took back nine acknowledged bookings out of 1,265 and left no trace of any of them.
  • Sending conflicts to the application changes nothing about how often they occur. It changes whether anyone is told.
  • A retry may be served by the other region, so every command needs an idempotency key stored with the data it creates.
  • The global health probe asks about readiness, and a region fails it only for a problem that is its own.

If you run a multi-region write workload and have a conflict rule that survived production, or one that did not, the comments are open and I would like to hear how it went.

ℹ️
HOW THIS WAS CHECKED

Everything in the sample runs in GitHub Actions on ubuntu-latest with .NET SDK 10.0.404, Python 3.13, Terraform 1.16.3, OpenTofu 1.12.6 and the AzureRM provider 5.2.0, with no Azure credentials: the 23-check self-test, the simulation in C# and in Python with identical JSON output, the nine-step drill against two API instances, terraform validate, and seven infrastructure tests on each tool. No Azure resource was created. Replication, failover and conflict behaviour of Cosmos DB and the routing behaviour of Front Door are taken from Microsoft’s documentation and were not measured. The workflow run has the logs, and the outputs are on the ci-results-terraform branch.

Glossary

  • Active-active— Every region serves reads and writes all the time, and the loss of one needs no operator action.
  • Replication window— The interval in which a write accepted in one region has not yet reached the others.
  • Last writer wins— A conflict rule that keeps the write with the higher timestamp and discards the other.
  • Conflicts feed— Where Cosmos DB places conflicts that no policy resolved, for the application to read and settle.
  • Compensation— A recorded follow-up action for the party that lost a conflict, such as a message and an offer of another slot.
  • Idempotency key— A client-generated identifier repeated on every retry of one request, so the server acts once.
  • Readiness— Whether an instance can serve requests now, as opposed to liveness, which is whether its process is running.
  • RPO— Recovery point objective: how much acknowledged data may be lost in a failure.

References & Further Reading


Discover more from C4: Container, Code, Cloud & Context

Subscribe to get the latest posts sent to your email.

Leave a comment

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.