Section 03

Scalability, availability & fault tolerance

How to handle more load (scale), stay up (availability), respond fast (performance), survive failures (fault tolerance) and recover from disasters (DR), plus the theory behind the trade-offs (CAP and PACELC).

3.1 Vertical vs Horizontal scaling

Vertical (scale up) = a bigger machine. Horizontal (scale out) = more machines behind a load balancer.

Vertical: scale up
2 vCPU / 8 GB
↓
8 vCPU / 32 GB
↓
64 vCPU / 512 GBhits a ceiling 🧱
Horizontal: scale out
Load balancer
↓ ↓ ↓
Node 1
Node 2
Node 3
Node N…
Vertical (up)Horizontal (out)
HowAdd CPU, RAM or disk to one nodeAdd more nodes
LimitHardware ceiling, cost rises steeplyAlmost unlimited
AvailabilitySingle point of failureRedundant. One node down is OK
ComplexitySimple, no code changeNeeds stateless apps, load balancing, distributed data
DowntimeOften needs a restartAdd or remove nodes live
TypicalRelational DB primary, legacy appsWeb/API tiers, microservices, NoSQL, Kafka
  • Auto-scaling = horizontal scaling triggered by metrics (CPU, queue length, request rate). Kubernetes HPA, VM Scale Sets, Azure Functions.
  • Start vertical (it's simple). Go horizontal when you need high availability or pass the ceiling.
  • The data tier is the hardest to scale out. See read replicas and sharding in Section 06.
  • Scale out fast, scale in slowly (cool-down) so capacity doesn't flap up and down.
How to implement it
Library / APIPackageUse it for
Stateless ASP.NET Corebuilt-inThe precondition for scaling out: no session or files on the instance (see 3.2)
Server GC + DATASbuilt-in runtime settingsServer GC uses every core when you scale up. DATAS (on by default since .NET 9) keeps the heap small, so many small containers stay cheap
AspireAspire.Hosting.AppHostRun several replicas locally (WithReplicas(3)) and deploy to Azure Container Apps
AutoscalingPlatform config, not code: Azure Container Apps / AKS (KEDA), App Service autoscaleAdd instances on CPU, request rate or queue length
Health checksbuilt-in AddHealthChecks / MapHealthChecksNew instances only get traffic once they report ready

Vertical vs Horizontal scaling 🏋️ vs 👥

Vertical: hire a stronger worker. Easy, but there's only so strong a person can get.

Horizontal: hire more workers. Unlimited, but now you need a manager (load balancer) and they can't keep notes in their heads (stateless).

Most real systems use both.

#SystemDesign #Cloud #Scalability

3.2 Stateless vs Stateful services Added

A stateless service keeps no client data in memory between requests, so any instance can serve any request.

Client
→
LB
→
Any app instancestateless
⇄
Redis / DBsession & state live here
  • Move state out of the app: sessions to Redis, files to blob storage, data to the DB, auth to tokens (JWT).
  • Sticky sessions are a workaround. They break even load spreading and lose sessions when a node dies.
  • Stateful workloads (DBs, Kafka, WebSocket servers) need special handling: StatefulSets, partitions, replication.
How to implement it
State to move outLibrary / API
Data Protection keysAzure.Extensions.AspNetCore.DataProtection.Blobs + .Keys (Key Vault), or Microsoft.AspNetCore.DataProtection.StackExchangeRedis. All instances must share keys, or cookies and antiforgery tokens fail on other instances
Sessionbuilt-in AddSession on IDistributedCache (Microsoft.Extensions.Caching.StackExchangeRedis)
AuthMicrosoft.AspNetCore.Authentication.JwtBearer: tokens carry the identity, so no server-side login state
Files / uploadsAzure.Storage.Blobs instead of the local disk
Cached dataHybridCache with a Redis L2 (see section 05)
var credential = new DefaultAzureCredential();
builder.Services.AddDataProtection()
    .SetApplicationName("payments-api")                    // same name on every instance
    .PersistKeysToAzureBlobStorage(new Uri(keysBlobUri), credential)
    .ProtectKeysWithAzureKeyVault(new Uri(keyVaultKeyId), credential);

builder.Services.AddStackExchangeRedisCache(o =>
    o.Configuration = builder.Configuration.GetConnectionString("redis"));
builder.Services.AddSession(o => o.IdleTimeout = TimeSpan.FromMinutes(20)); // stored in Redis

var app = builder.Build();
app.UseSession();

3.3 Load balancing

A load balancer spreads traffic across healthy servers to improve throughput, availability and response time.

Users
→
DNS / Global LBFront Door · Traffic Manager
→
Regional L7 LBApp Gateway / ALB + WAF
→
Server poolhealth-checked

Algorithms

Round robin

1 → 2 → 3 → 1… Simple. Assumes servers are equal.

Weighted round robin

Bigger servers get more traffic. Also used for canary releases (90/10).

Least connections

Send to the server with the fewest active connections. Good for long requests.

Least response time

Pick the fastest healthy server.

IP hash / sticky

The same client goes to the same server. Session affinity.

Consistent hashing

Key-based routing that barely reshuffles when nodes change (caches, shards).

L4 (transport)L7 (application)
SeesIP, port, TCP/UDPURL path, host, headers, cookies
Can doVery fast forwardingPath routing, TLS termination, WAF, rewrites
ExamplesAzure Load Balancer, AWS NLBAzure App Gateway, AWS ALB, NGINX, Envoy
  • Health checks (liveness/readiness) take bad nodes out automatically.
  • TLS termination at the LB offloads crypto work. Re-encrypt to the backend if compliance needs end-to-end TLS.
  • The LB itself must be redundant (managed cloud LBs are).
  • Global LB = DNS-based or Anycast, for multi-region active-active and failover.
How to implement it
Library / APIPackageUse it for
YARPYarp.ReverseProxyA reverse proxy / L7 load balancer in .NET. Policies: PowerOfTwoChoices (default), RoundRobin, LeastRequests, Random, FirstAlphabetical
YARP health checksYarp.ReverseProxyActive (probe a path) and passive (watch real failures) checks take bad servers out
YARP session affinityYarp.ReverseProxySticky sessions via a cookie or header, when you can't avoid state
Service discoveryMicrosoft.Extensions.ServiceDiscoveryClient side: HttpClient calls https+http://catalog and it resolves to the real endpoints
Health endpointsbuilt-in MapHealthChecksWhat Azure Front Door, App Gateway or Kubernetes probe
// Program.cs
builder.Services.AddReverseProxy().LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"));
app.MapReverseProxy();

// appsettings.json
"ReverseProxy": {
  "Routes":   { "api": { "ClusterId": "api", "Match": { "Path": "/api/{**catch-all}" } } },
  "Clusters": {
    "api": {
      "LoadBalancingPolicy": "LeastRequests",
      "HealthCheck": {
        "Active":  { "Enabled": true, "Interval": "00:00:10", "Path": "/health/ready" },
        "Passive": { "Enabled": true }
      },
      "Destinations": {
        "a": { "Address": "https://10.0.1.4/" },
        "b": { "Address": "https://10.0.1.5/" }
      }
    }
  }
}

3.4 Consistent hashing Added

Place nodes and keys on a hash ring. Each key belongs to the next node clockwise, so adding a node only moves about 1/N of the keys.

hash ring key → next node clockwise ↻ A B C D Node A Node B Node C Node D E Node E joins Add a 5th node: how many keys move? hash(key) mod N (N: 4 → 5) ✗ almost ALL keys move (about 80%) consistent hashing ✓ only the neighbour's keys move (about 1/N) Virtual nodes: each server appears many times on the ring, so the keys spread evenly

Watch Node E join: only the keys between Node A and Node E (they used to belong to Node B) move to E. Every other key stays where it is. With hash(key) mod N, changing N from 4 to 5 sends almost every key to a different node. Virtual nodes = each server appears many times on the ring, which gives an even spread.

  • Used in Cassandra, DynamoDB, Redis Cluster (hash slots), CDNs and distributed caches.
How to implement it
Library / APIPackageUse it for
XxHash3 / XxHash64System.IO.HashingFast, stable hash for ring positions. Don't use string.GetHashCode(): it changes on every process start
Sorted array + BinarySearchbuilt-in SortedList, Array.BinarySearchFind the next node clockwise in O(log n). .NET has no built-in hash ring
Redis ClusterStackExchange.RedisRedis uses 16,384 hash slots instead of a ring; the client routes each key for you
Cosmos DB / Kafka partitionsMicrosoft.Azure.Cosmos, Confluent.KafkaPick a good partition key; the service does the hashing
public sealed class HashRing
{
    private readonly ulong[] _positions;   // sorted points on the ring
    private readonly string[] _nodes;      // node that owns each point

    public HashRing(IEnumerable<string> nodes, int virtualNodes = 100)
    {
        var ring = new SortedList<ulong, string>();
        foreach (var node in nodes)
            for (var i = 0; i < virtualNodes; i++)
                ring[Hash($"{node}#{i}")] = node;   // many points per node = even spread
        _positions = [.. ring.Keys];
        _nodes = [.. ring.Values];
    }

    public string GetNode(string key)
    {
        var i = Array.BinarySearch(_positions, Hash(key));
        if (i < 0) i = ~i;                                // first point clockwise
        return _nodes[i == _positions.Length ? 0 : i];    // wrap around the ring
    }

    private static ulong Hash(string s) => XxHash3.HashToUInt64(Encoding.UTF8.GetBytes(s));
}

3.5 Fault tolerance

The system keeps working, maybe in a reduced mode, when parts of it fail. Design assuming everything fails eventually.

High availability

Minimise downtime with redundancy and fast failover. A brief blip is OK.

Fault tolerance

No interruption at all when a component fails. Costs more.

Resilience

Absorb the failure, degrade gracefully and recover automatically.

How it's achieved

✓ All zones healthy ✗ Zone 3 down → traffic shifts to Zones 1 and 2 Load balancer Region 1 (primary) Region 2 (DR) App App App App stateless, auto-scaled stateless, auto-scaled DB Primary takes the writes DB Replica zone-redundant data App App stateless, auto-scaled DB Replica zone-redundant data ✗ ZONE DOWN Zone 1Zone 2Zone 3 sync sync Standby apps DB copy for disaster recovery async replication → Region 2 (DR)
How to implement it
Library / APIPackageUse it for
Health checksbuilt-in Microsoft.Extensions.Diagnostics.HealthChecks; dependency checks: AspNetCore.HealthChecks.* (community)Liveness and readiness probes, so the platform restarts or removes a broken instance
FallbackPolly.Core (AddFallback)Degrade gracefully: return cached or default data when a dependency fails
DB retriesEF Core EnableRetryOnFailure()Ride through transient SQL errors and failovers
Azure SDK retriesClientOptions.Retry (all Azure.* clients)Built-in exponential retry for Storage, Service Bus, Key Vault and others
Durable messagingAzure.Messaging.ServiceBusWork waits in a queue while a consumer is down; poison messages go to the dead-letter queue
builder.Services.AddHealthChecks()
    .AddCheck("self", () => HealthCheckResult.Healthy(), tags: ["live"])
    .AddSqlServer(builder.Configuration.GetConnectionString("sql")!, tags: ["ready"])   // AspNetCore.HealthChecks.SqlServer
    .AddDbContextCheck<AppDbContext>(tags: ["ready"]);   // ...HealthChecks.EntityFrameworkCore

builder.Services.AddDbContext<AppDbContext>(o =>
    o.UseSqlServer(builder.Configuration.GetConnectionString("sql"),
        sql => sql.EnableRetryOnFailure()));   // retries transient errors

app.MapHealthChecks("/health/live",  new() { Predicate = c => c.Tags.Contains("live") });
app.MapHealthChecks("/health/ready", new() { Predicate = c => c.Tags.Contains("ready") });

Keep liveness checks shallow (is the process alive?). If liveness checks the database, a DB outage makes the platform restart every instance at once.

3.6 Resilience patterns: timeout, retry, circuit breaker, bulkhead Added

Code-level patterns that stop one slow or failing dependency from taking everything down with it.

⏱ Timeout

Never wait forever. Set connect and read timeouts below the caller's own timeout.

🔁 Retry + exponential backoff + jitter

Retry only transient errors (503, timeouts): 100 ms, 200 ms, 400 ms + random. Cap the attempts. Only retry idempotent operations.

🔌 Circuit breaker

After N failures, stop calling the dependency and fail fast. Probe again later.

🚢 Bulkhead

Give each dependency its own thread or connection pool, so one bad one can't use up all resources.

🪂 Fallback

Default value, cached response or friendly message.

🚦 Rate limit / load shedding

Reject extra load early (429/503) to protect the core.

Circuit breaker states

CLOSEDcalls flow, count failures
→failures ≥ threshold
OPENfail fast, no calls
→after cool-down
HALF-OPENlet a few test calls through
→success → CLOSED / fail → OPEN
  • Retries without backoff or jitter cause retry storms that make an outage worse.
  • Retry in one layer only. Retrying at every layer multiplies traffic (3 layers × 3 retries = 27×).
  • Libraries: Polly (.NET), Resilience4j (Java), and service mesh / APIM policies.
How to implement it
PatternLibrary / API
All-in-one for HttpClient ★Microsoft.Extensions.Http.Resilience: AddStandardResilienceHandler() = concurrency limit + total timeout + retry + circuit breaker + per-attempt timeout, with good defaults
Timeout / retry / circuit breakerPolly v8 (Polly.Core) ResiliencePipelineBuilder: AddTimeout, AddRetry (exponential backoff + jitter), AddCircuitBreaker
BulkheadPolly.RateLimiting (AddConcurrencyLimiter), or a SemaphoreSlim per dependency
HedgingAddStandardHedgingHandler(): send a second request if the first is slow
Non-HTTP callsMicrosoft.Extensions.Resilience: AddResiliencePipeline("name", ...) + ResiliencePipelineProvider<string>, with telemetry
// HttpClient: one line gives timeout, retry, circuit breaker and bulkhead
builder.Services.AddHttpClient<FxClient>(c => c.BaseAddress = new("https://fx.internal/"))
    .AddStandardResilienceHandler(o =>
    {
        o.AttemptTimeout.Timeout = TimeSpan.FromSeconds(2);
        o.TotalRequestTimeout.Timeout = TimeSpan.FromSeconds(10);
        o.Retry.MaxRetryAttempts = 3;               // backoff + jitter by default
    });

// Anything else (DB, SDK): a named Polly v8 pipeline. First added = outermost
builder.Services.AddResiliencePipeline("ledger", p => p
    .AddRetry(new() { MaxRetryAttempts = 3, BackoffType = DelayBackoffType.Exponential, UseJitter = true })
    .AddCircuitBreaker(new() { FailureRatio = 0.5, MinimumThroughput = 10, BreakDuration = TimeSpan.FromSeconds(30) })
    .AddTimeout(TimeSpan.FromSeconds(3)));

// Usage (inject ResiliencePipelineProvider<string> provider)
await provider.GetPipeline("ledger").ExecuteAsync(async t => await ledger.PostAsync(entry, t), ct);

Only retry idempotent operations, or send an idempotency key. Retrying a payment POST without one can charge the customer twice. The standard handler retries every method by default; call o.Retry.DisableForUnsafeHttpMethods() to stop that.

The circuit breaker pattern is literally your home fuse box ⚡

Too many failures → trip (OPEN) → stop calling the broken service → after a cool-down, try one call (HALF-OPEN) → healthy? Back to normal (CLOSED).

Fail fast > fail slow. A slow failure takes your threads, your users and your weekend.

#Resilience #Microservices #SRE

3.7 Availability (HA & "the nines") Added

Availability is the percentage of time a system is up and working correctly for its users. High availability (HA) means designing it so failures cause little or no downtime.

How it's measured

⏱ Time-based

Availability = uptime ÷ (uptime + downtime)

= MTBF ÷ (MTBF + MTTR)

MTBF = mean time between failures. MTTR = mean time to recover.

📨 Request-based (preferred for APIs)

Availability = successful requests ÷ total valid requests

e.g. 999,000 good out of 1,000,000 = 99.9%. This reflects what users actually experienced.

Availability

Is it up and answering right now?

Reliability

Does it work correctly over time, without failing? (MTBF)

Durability

Is the data kept safe and never lost? (Storage: 11 nines of durability ≠ 11 nines of availability.)

Resilience

How well does it absorb failures and recover?

The nines

AvailabilityDowntime / yearDowntime / month
99% (two nines)3.65 days7.3 h
99.9%8.77 h43.8 min
99.95%4.38 h21.9 min
99.99%52.6 min4.4 min
99.999%5.26 min26 s

Composite availability

In series (A calls B): multiply.
99.9% × 99.9% = 99.8%. Each dependency lowers the total.

In parallel (redundant copies): 1 − (1 − a)ⁿ
Two 99% nodes → 1 − 0.01² = 99.99%.

Redundancy raises availability. Long dependency chains lower it.

How to achieve high availability: redundancy at every layer

LayerHA techniqueAzure examples
Edge / DNSGlobal load balancing, multi-region routing, health-based failoverFront Door, Traffic Manager
Compute2+ stateless instances spread across availability zones, auto-scale, auto-healApp Service zone redundancy, AKS across zones, VM Scale Sets
GatewayMultiple units across zones or regionsAPIM Premium with zones / multi-region
DataSynchronous replicas in-region, async geo-replica, automatic failoverAzure SQL zone-redundant + failover groups, Cosmos DB multi-region
MessagingZone-redundant broker, geo-disaster-recovery pairingService Bus Premium, Event Hubs geo-DR
DependenciesTimeouts, circuit breakers, cached fallbacks, async decouplingPolly, APIM policies, queues
ReleasesZero-downtime deployments and fast rollbackBlue-green, canary, deployment slots
OperationsHealth probes, alerting, runbooks, automated failover, DR drillsAzure Monitor, Chaos Studio

Active-active

All sites serve traffic at the same time.

  • ➕ Near-zero failover time, capacity is already warm
  • ➖ Costly. Data consistency across sites is hard

Active-passive

A standby takes over when the primary fails (hot, warm or cold).

  • ➕ Simpler, cheaper
  • ➖ Failover takes time. The standby may be untested or stale
  • Two levers: fail less often (raise MTBF: redundancy, testing, change control) and recover faster (cut MTTR: detection, automated failover, rollback). Recovering faster is usually cheaper.
  • Changes cause most outages. Safe deployment practices protect availability as much as redundancy does.
  • Agree whether planned maintenance counts as downtime in the SLA.
  • Availability is only as high as your weakest required dependency, including DNS, the identity provider, certificates and third-party APIs.
  • Pick a target from the business impact. 99.99% costs far more than 99.9%. Don't over-engineer internal or batch systems.
How to implement it
Library / APIPackageUse it for
Health checks + probesbuilt-in MapHealthChecksLoad balancers route around failed instances and zones (see 3.5)
SQL connection resiliencyMicrosoft.Data.SqlClientConnectRetryCount / ConnectRetryInterval reconnect after a blip; connect to the failover-group listener so failover needs no config change
Cosmos DB multi-regionMicrosoft.Azure.CosmosApplicationPreferredRegions: the SDK reads from the nearest region and moves to the next one if it fails
Geo-redundant storage readsAzure.Storage.BlobsGeoRedundantSecondaryUri: reads fall back to the secondary region (RA-GRS)
Availability SLIbuilt-in http.server.request.duration metricCount successful vs failed requests to measure "the nines" (see 3.10)
// Cosmos DB: nearest region first, automatic move to the next if it fails
var cosmos = new CosmosClient(endpoint, new DefaultAzureCredential(), new CosmosClientOptions
{
    ApplicationPreferredRegions = [Regions.AustraliaEast, Regions.AustraliaSoutheast]
});

Want more nines? Fix your MTTR, not just your MTBF ⏱

Availability = MTBF ÷ (MTBF + MTTR)

Fail once a month, recover in 1 hour → 99.86%

Same failures, recover in 6 minutes → 99.986%

Redundancy, health checks, automated failover and one-click rollback beat "we'll never fail".

#HighAvailability #SRE #SystemDesign

3.8 Performance Added

Performance is how fast a system responds and how much work it can get through with the resources it has.

Key metrics

⏱ Latency / response time

Time for one request. Track p50, p95 and p99, not the average.

🚚 Throughput

Work per second: requests/s, TPS, messages/s, MB/s.

📊 Utilisation

How busy the resources are: CPU, memory, connections, threads, DB DTU.

❌ Error rate under load

Timeouts and 5xx often appear only when load rises.

Performance vs scalability

Performance problem

The system is slow even for one user. Fix the code, query, algorithm or network path.

Scalability problem

Fast for one user, but slows down as load grows. Fix capacity, contention or the architecture (scale out, cache, queue, partition).

Why things slow down under load: queueing

Response time = network + waiting in queue + service time. Waiting time explodes as utilisation approaches 100% (simple queue model: R = S ÷ (1 − U), where S is the service time and U the utilisation).

keep here latency (× service time) 1×5×10× 0%50%100% 60–70% utilisation → wait → ∞ U = 50% → 2× U = 80% → 5× U = 90% → 10× service time

Where the time goes, and what to do about it

LayerCommon bottleneckFixes
Client / edgeDistance, big payloads, many round tripsCDN, compression, HTTP/2–3, bundle calls, put data in the same region
Gateway / networkTLS handshakes, extra hops, chatty servicesKeep-alive, connection reuse, fewer hops, gRPC internally
ApplicationSequential calls, blocking I/O, inefficient codeParallel calls, async I/O, better algorithms, profiling, caching
DataSlow queries, missing indexes, lock contention, N+1Indexes, query tuning, read replicas, batching, denormalising
InfrastructureCPU, memory or connection pool exhaustedRight-size, auto-scale, pool tuning, load balancing
Long-running workUser waits for heavy processingReturn 202 and process asynchronously through a queue

The tuning loop

1. Set a targetp95 < 300 ms at 500 TPS
→
2. MeasureAPM, traces, load test
→
3. Find the biggest bottleneckprofile, trace waterfall
→
4. Fix that one thing
→
5. Re-measure ↺

Performance testing types

TestQuestion it answersHow
LoadDoes it meet targets at expected peak?Ramp to peak load, hold it
StressWhere does it break, and how does it fail?Push beyond peak until it fails
SpikeCan it handle a sudden surge (salary day, a promotion)?Jump instantly from low to very high load
Soak / enduranceDoes it degrade over hours (memory leaks, pool exhaustion)?Moderate load for 8–24 h or more
Capacity / scalabilityHow much load per instance? When must we add more?Increase load step by step and track resource use
  • Write performance targets as measurable NFRs, for example "p95 < 300 ms at 500 TPS with error rate < 0.1%".
  • Test with production-like data volumes and environments. A query that's fast on 1,000 rows can be slow on 100 million.
  • Fix the biggest bottleneck first. Speeding up anything else won't help (Amdahl's law).
  • Keep headroom. Run at 60–70% utilisation, not 95%.
  • Tools: JMeter, k6, Gatling, Azure Load Testing for testing. App Insights, Datadog, Dynatrace for APM and profiling.
  • For API-specific techniques, see 8.5 · API performance. For caching, see 05 · Caching.
How to implement it
Library / APIPackageUse it for
BenchmarkDotNetBenchmarkDotNetMicro-benchmarks: time and allocations per call, compared with a baseline
Diagnostics toolsdotnet-counters, dotnet-trace, dotnet-dump (.NET global tools)Live metrics, CPU traces and memory dumps from a running app, including in containers
Fewer allocationsbuilt-in Span<T>, Memory<T>, ArrayPool<T>; Microsoft.Extensions.ObjectPoolLess GC work under load, so better p99 latency
Fast I/O and JSONbuilt-in System.IO.Pipelines, System.Text.Json source generationParse streams and serialise without reflection
Do less workbuilt-in output caching (AddOutputCache), response compression (AddResponseCompression)Skip repeated work; send fewer bytes
Native AOTbuilt-in <PublishAot>true</PublishAot>Fast startup and small memory, good for scale-to-zero. Not every library supports it
[MemoryDiagnoser]                                   // also shows bytes allocated per call
public class ParseBench
{
    private readonly string _line = "2026-10-16,ACC123,150.25";

    [Benchmark(Baseline = true)]
    public decimal WithSplit() => decimal.Parse(_line.Split(',')[2]);   // allocates 3 strings

    [Benchmark]
    public decimal WithSpan()
    {
        var s = _line.AsSpan();
        return decimal.Parse(s[(s.LastIndexOf(',') + 1)..]);            // no allocations
    }
}
// Run in Release: BenchmarkRunner.Run<ParseBench>();

Measure first. Find the real bottleneck with dotnet-counters / dotnet-trace (often the DB or a chatty remote call) before tuning C# code.

Is your system slow, or just busy? 🐢 vs 🚦

Slow for ONE user → a performance problem (code, query, network).

Fast for one, slow for MANY → a scalability problem (capacity, contention).

And watch utilisation: at 50% busy, requests wait about 2× longer. At 90%, about 10× longer. Keep headroom.

Measure → find the biggest bottleneck → fix → re-measure.

#Performance #SystemDesign #SRE

3.9 Capacity planning & cost (FinOps) Added

Capacity planning makes sure you have enough resources for the peak, with headroom. FinOps makes sure you don't pay for much more than that.

Capacity planning in 6 steps

1. Baselinetoday's load, p95, CPU per request
→
2. Forecastgrowth, seasonality, campaigns
→
3. Add headroomrun at 60–70%, N+1 for failures
→
4. Load testprove the per-instance limit
→
5. Set autoscale limitsmin, max, quotas, DB limits
→
6. Reviewmonthly, after big events

Little's Law: how much concurrency do I need?

L = λ × W

L = average number of requests in the system (in flight)

λ (lambda) = arrival rate (requests per second)

W = average time each request spends in the system

Works for any stable system: APIs, queues, call centres, bank branches.

🧮 Example

Peak = 400 requests/s, average response = 0.25 s.

L = 400 × 0.25 = 100 requests in flight.

Load test shows one instance handles 25 concurrent requests at 65% CPU → 100 ÷ 25 = 4 instances, + 1 for a failure (N+1) = 5.

If a slow dependency doubles W to 0.5 s, L doubles to 200. Latency eats capacity.

Autoscaling: reactive vs scheduled vs predictive

TypeHowGood forWatch out
ReactiveScale when a metric crosses a threshold (CPU > 70%, queue length, requests/s)Unpredictable loadLag: new instances take minutes to start and warm up. Set a max
ScheduledCron rules: "6 instances from 06:30 to 18:00 on weekdays" (KEDA cron scaler, App Service / VMSS schedules)Known patterns: business hours, batch windows, paydayWasted spend if the pattern changes
PredictiveThe platform learns history and scales ahead of the forecast (Azure VMSS predictive autoscale, AWS EC2 predictive scaling)Regular cycles with a slow warm-upNeeds history. Combine with reactive for surprises

FinOps: cost as a shared responsibility

Informwho spends what: tags, showback, unit cost
→
Optimiseright-size, commit, use spot, delete waste
→
Operatebudgets, alerts, policies, reviews
↻

📏 Unit economics

Track cost per transaction, per customer or per payment, not just the total bill. A rising bill with a falling unit cost is healthy growth.

📉 Right-sizing

Most VMs and DBs are over-provisioned. Use utilisation data (Azure Advisor, AWS Compute Optimizer) to step down a size. Turn off non-prod at night.

🤝 Commitments

Reservations and savings plans (1 or 3 years) cut the price of steady, always-on load. Commit to the baseline only, not the peak.

⚡ Spot / preemptible

Big discounts on spare capacity, but the cloud can take it back at short notice. Only for interruptible work: batch, CI, stateless workers.

🏷 Tagging

Tag every resource (app, env, owner, cost-centre) and enforce it with policy. No tags = no showback.

🔔 Budgets & alerts

Budgets per subscription or team, alerts at 50/80/100% and on anomalies. A forgotten GPU or log ingestion spike shows up in days, not at month end.

  • Little's Law: concurrency = throughput × latency. Cut latency and you need fewer instances.
  • Write cost as an NFR: "under $0.002 per payment at 500 TPS". Then it can be designed, tested and traded off like latency.
  • Commit (reserve) for the baseline, autoscale for the peak, spot for interruptible work.
  • Scaling has limits you can't buy your way past quickly: quotas, DB connections, downstream rate limits. Find them in the load test, not in production.
How to implement it
Library / APIPackageUse it for
Concurrency and throughput metricsbuilt-in System.Diagnostics.Metrics; ASP.NET Core http.server.active_requestsMeasure L and λ for Little's Law per instance. Watch live with dotnet-counters monitor
Load testing in C#NBomber commercial licenceFind the per-instance limit. Free for personal use only; organisations need a paid licence. Free alternatives: k6, JMeter, Azure Load Testing
Micro-benchmarksBenchmarkDotNetCost per call of hot code paths (see 3.8)
Event-driven autoscalePlatform config: KEDA scalers in Azure Container Apps / AKS (Service Bus, Kafka, HTTP, cron)Scale workers on queue length; scheduled scaling with the cron scaler
Cost queriesAzure.ResourceManager.CostManagementQuery cost by tag or resource group and build cost-per-transaction reports
Usage metricsAzure.Monitor.Query.Metrics, Azure.Monitor.Query.LogsPull utilisation and request counts for capacity reviews. These replace the older Azure.Monitor.Query package, now deprecated
// Track in-flight work (L) and completed work (λ) for a queue worker
public sealed class WorkerMetrics
{
    private readonly UpDownCounter<long> _inFlight;   // L
    private readonly Counter<long> _done;             // its rate = λ

    public WorkerMetrics(IMeterFactory meterFactory)
    {
        var meter = meterFactory.Create("Bank.Payments.Worker");
        _inFlight = meter.CreateUpDownCounter<long>("payments.in_flight");
        _done = meter.CreateCounter<long>("payments.processed");
    }

    public async Task TrackAsync(Func<Task> work)
    {
        _inFlight.Add(1);
        try { await work(); _done.Add(1); }
        finally { _inFlight.Add(-1); }
    }
}
// dotnet-counters monitor -n PaymentsWorker --counters Bank.Payments.Worker

One formula every architect should know: L = λ × W 🧮

📥 λ = how many requests arrive per second

⏱ W = how long each one takes

🔄 L = how many are in flight at once, which is the capacity you must provide

400 req/s × 0.25 s = 100 in flight. If a slow dependency doubles latency, you need double the capacity. Latency is a cost.

💸 Then FinOps: reserve the baseline, autoscale the peak, use spot for batch, and track cost per transaction, not just the bill.

#CapacityPlanning #FinOps #Cloud #SystemDesign

3.10 SLA vs SLO vs SLI & error budgets Added

SLI is what you measure, SLO is the target you set, SLA is what you promise in a contract (with penalties).

SLI99.95% of requests < 300 ms (measured)
→
SLOinternal target: 99.9%
→
SLAcontract: 99.5%, or service credits
  • Set SLO stricter than SLA, so you get warned before you break the contract.
  • Error budget = 100% − SLO. At 99.9%, you get 43 min/month for incidents and risky releases. Budget used up → freeze features, fix reliability.
  • Good SLIs: availability (success rate), latency (p95/p99), freshness, correctness.
How to implement it
Library / APIPackageUse it for
Built-in HTTP metricsbuilt-in http.server.request.durationLatency and status code for every request: the raw data for availability and latency SLIs
Custom SLIsbuilt-in System.Diagnostics.Metrics (Counter, Histogram)Business SLIs, e.g. "payments settled within 5 s"
OpenTelemetryOpenTelemetry.Extensions.Hosting + an exporter (OpenTelemetry.Exporter.OpenTelemetryProtocol)Send metrics to Prometheus / Grafana or any OTLP backend
Azure MonitorAzure.Monitor.OpenTelemetry.AspNetCoreOne call (UseAzureMonitor) sends metrics, traces and logs to Application Insights
builder.Services.AddOpenTelemetry()
    .UseAzureMonitor()                                 // Azure.Monitor.OpenTelemetry.AspNetCore
    .WithMetrics(m => m.AddMeter("Bank.Payments"));    // plus our custom meter

public sealed class PaymentSli
{
    private readonly Counter<long> _total;
    private readonly Counter<long> _good;

    public PaymentSli(IMeterFactory meterFactory)
    {
        var meter = meterFactory.Create("Bank.Payments");
        _total = meter.CreateCounter<long>("payments.sli.total");
        _good  = meter.CreateCounter<long>("payments.sli.good");
    }

    public void Record(bool succeeded, TimeSpan settleTime)
    {
        _total.Add(1);
        if (succeeded && settleTime < TimeSpan.FromSeconds(5)) _good.Add(1);   // SLI = good / total
    }
}

The SLO maths (good / total over 30 days, burn-rate alerts) lives in the monitoring tool (KQL, PromQL), not in app code. The app's job is to emit honest metrics.

3.11 CAP theorem

When a network Partition splits a distributed data store, it must choose between Consistency (everyone sees the latest write) and Availability (every request gets an answer).

C A P Consistency: every read gets the latest write Availability: every request answered Partition tolerance: survives a network split CA* single node / no partitions CP ZooKeeper, etcd, HBase, MongoDB (majority) AP Cassandra, DynamoDB, CouchDB

How it plays out

  • It's not "pick any 2 of 3". In a distributed system, partitions will happen, so P is a given. The real choice is C or A during a partition.
  • "C" in CAP means linearizability, which is different from the "C" in ACID (constraint validity).
  • Banking ledgers and payments usually choose CP. Social feeds, carts and catalogues often choose AP.
  • Many databases let you tune this per request (Cassandra consistency levels, Cosmos DB's 5 levels).
How to implement it
Library / APIUse it for
Microsoft.Azure.Cosmos ConsistencyLevelCAP is a theory, but Cosmos DB lets you choose the trade-off: Strong (CP-like across regions) down to Eventual (AP). Details in 3.12
CassandraCSharpDriver ConsistencyLevelPer query: LocalQuorum leans to C, One leans to A

CAP theorem in 20 seconds ⏱

Network splits happen. When they do, your database must choose:

🔵 Consistency: "I'd rather say nothing than say something wrong." (payments, ledgers)

🟢 Availability: "I'll answer, even if it's a bit stale." (likes, product catalogue)

It's not "pick 2 of 3". It's "pick C or A when P happens".

#SystemDesign #DistributedSystems #Databases

3.12 PACELC & consistency models Added

PACELC extends CAP: if there's a Partition choose A or C; Else (normal operation) choose Latency or Consistency.

Examples

  • PA/EL: Cassandra, DynamoDB. Fast, eventually consistent.
  • PC/EC: traditional RDBMS clusters, Spanner. Always consistent.
  • PA/EC or tunable: MongoDB, Cosmos DB.

Consistency spectrum (strong → weak)

  • Strong / linearizable: reads always see the latest write
  • Bounded staleness: at most X seconds or versions behind
  • Session / read-your-writes: you always see your own updates
  • Causal: cause is seen before effect
  • Eventual: copies converge… eventually
  • Quorum rule: with N replicas, if writes go to W and reads to R where R + W > N, reads see the latest write. Example: N=3, W=2, R=2.
How to implement it
Library / APIPackageUse it for
Cosmos DB consistencyMicrosoft.Azure.CosmosFive levels: Strong, BoundedStaleness, Session (default), ConsistentPrefix, Eventual. Set on the account; a client or request can only make it weaker
Cassandra consistencyCassandraCSharpDriverTune per statement (SetConsistencyLevel): latency vs consistency on every query
Optimistic concurrencyEF Core [Timestamp] / rowversionStop lost updates: a stale write throws DbUpdateConcurrencyException
Read replicasMicrosoft.Data.SqlClient ApplicationIntent=ReadOnlySend reads to a readable secondary only when slightly stale data is OK
// Cosmos: the account default is Session; relax it for one read where latency matters more
var options = new ItemRequestOptions { ConsistencyLevel = ConsistencyLevel.Eventual };
var item = await container.ReadItemAsync<Quote>(id, new PartitionKey(pair), options);

// EF Core: optimistic concurrency with a SQL Server rowversion column
public class Account
{
    public int Id { get; set; }
    public decimal Balance { get; set; }
    [Timestamp] public byte[] RowVersion { get; set; } = [];
}

3.13 Disaster recovery: RPO, RTO & DR strategies Added

RPO = how much data you can afford to lose. RTO = how long you can afford to be down.

StrategyHowRTO / RPOCost
Backup & restoreRestore from backups into a new environmentHours–days / hours💲
Pilot lightData replicated, core infra kept minimal and "off"10s of minutes–hours / minutes💲💲
Warm standbyScaled-down full copy running in the DR regionMinutes / seconds–minutes💲💲💲
Multi-site active-activeBoth regions serve live trafficNear zero / near zero💲💲💲💲
  • Business sets RPO/RTO through a Business Impact Analysis (BIA). Tech picks the strategy to meet them.
  • Test DR regularly. An untested DR plan is just a wish.
  • For Australian regulated entities, APRA CPS 230 (operational resilience) and CPS 234 (information security) shape DR and BCP expectations.
How to implement it
Library / APIPackageUse it for
Azure SQL failover groupsMicrosoft.Data.SqlClientConnect to the listener (<fog>.database.windows.net, read-only: <fog>.secondary.database.windows.net). After failover the same name points to the new primary
Geo-redundant blobsAzure.Storage.BlobsBlobClientOptions.GeoRedundantSecondaryUri: keep reading from the secondary region (RA-GRS / RA-GZRS) during an outage
Cosmos DBMicrosoft.Azure.CosmosMulti-region account + ApplicationPreferredRegions; RPO depends on the consistency level
DR automationAzure.ResourceManager.* (e.g. Azure.ResourceManager.Sql)Script failover and DR drills from a runbook or pipeline
Rebuild the regionInfrastructure as code (Bicep / Terraform), not app codeRebuild the secondary region the same way every time
// SQL: always connect to the failover-group listener, never a server name
var sql = "Server=tcp:payments-fog.database.windows.net;Database=Payments;" +
          "Authentication=Active Directory Default;ConnectRetryCount=3";

// Blobs: read from the secondary region if the primary is down (RA-GRS)
var blobs = new BlobServiceClient(new Uri("https://paymentsdocs.blob.core.windows.net"),
    new DefaultAzureCredential(),
    new BlobClientOptions
    {
        GeoRedundantSecondaryUri = new Uri("https://paymentsdocs-secondary.blob.core.windows.net")
    });

Test it. A DR plan that has never been run in a drill usually misses its RTO because something was forgotten: DNS caches, firewall rules, secrets or connection strings in the second region.

3.14 Cell-based architecture & multi-region active-active Added

Split the whole stack (app, data, cache, queues) into independent copies called cells, each serving a slice of customers, so one failure or bad deployment only hurts that slice.

🧱 Cell

A complete, self-contained copy of the service with its own database. Sized to a known maximum (e.g. 50,000 customers). Azure calls this the Deployment Stamps pattern.

🧭 Cell router

A thin, very simple layer that maps a cell key (tenant, customer or account ID) to a cell and forwards the request. It must be the most reliable part of the system.

💥 Blast radius

With 10 cells, a poison request, bad config or DB outage affects about 10% of customers, not 100%.

📈 Scale by adding cells

Instead of growing one giant cluster, add a new cell when existing cells are full. Each cell stays a size you've tested.

Across regions: active-active vs active-passive vs pilot light

ModelSecond regionFailoverCost and complexity
Active-activeServes live traffic all the timeNear zero: shift trafficHighest. Each region needs headroom to take everything, and data conflicts must be handled
Active-passive (warm standby)Running, scaled down, not servingMinutes: scale up, switch trafficMedium. Simpler data (one writer)
Pilot lightOnly data is replicated; compute is off or minimalTens of minutes or more: deploy and start computeLow. Needs good automation (IaC)

These are the same strategies as the DR table in 3.13, seen from the traffic side.

Data replication and conflicts

Global traffic managers

ServiceHow it routesNotes
Azure Front DoorGlobal anycast HTTP(S) proxy (L7)Health probes, WAF, CDN, latency- or priority-based routing. Fast failover because it doesn't rely on DNS TTLs
Azure Traffic ManagerDNS-basedAny protocol. Failover speed depends on DNS TTL and client caching
Amazon Route 53DNS-based routing policiesFailover, latency, weighted and geolocation routing with health checks
AWS Global AcceleratorAnycast IPs (L4)TCP/UDP traffic to the nearest healthy region

✓ Worth it when

  • The availability target is 99.99% or higher, or a region outage is unacceptable to the business or regulator
  • Large multi-tenant platforms where one noisy or broken tenant must not hurt the rest
  • You need to deploy safely in waves at scale

✗ Probably not when

  • Single-region with zones already meets the SLO
  • The team can't yet automate deployments and failover
  • Data must be strongly consistent across regions on every write and the latency cost is unacceptable
  • Cells shrink the blast radius; multi-region protects against region loss. Big platforms combine them: several cells per region.
  • Keep the cell router dumb and static (a lookup table), and cache the mapping. A complex router becomes the new single point of failure.
  • Active-active only works if each region can take the full load, and you've decided how write conflicts are avoided or resolved.
  • Test failover regularly. Traffic shifting is easy; data and capacity are where it breaks.
How to implement it
Library / APIPackageUse it for
Cell routerYarp.ReverseProxyRoute by a cell key: match a header or path per route, one cluster per cell. Or pick the cluster in code from a tenant → cell lookup
Service discoveryMicrosoft.Extensions.ServiceDiscoveryResolve each cell's endpoints by name (https+http://cell-2-api) from configuration instead of hard-coded URLs
Multi-region dataMicrosoft.Azure.CosmosMulti-region writes are an account setting. ApplicationPreferredRegions picks the nearest region; ConflictResolutionPolicy is last writer wins (default) or a custom stored procedure
Health probesbuilt-in MapHealthChecksWhat Front Door, Traffic Manager or Route 53 probe to decide a region or cell is healthy
// YARP: the edge sets X-Cell from a tenant lookup; each route sends one cell to its cluster
"Routes": {
  "cell-1": { "ClusterId": "cell-1",
              "Match": { "Path": "{**catch-all}", "Headers": [ { "Name": "X-Cell", "Values": [ "cell-1" ] } ] } },
  "cell-2": { "ClusterId": "cell-2",
              "Match": { "Path": "{**catch-all}", "Headers": [ { "Name": "X-Cell", "Values": [ "cell-2" ] } ] } }
}

// Cosmos DB: nearest region first; last-writer-wins on a property you control
var cosmos = new CosmosClient(endpoint, new DefaultAzureCredential(), new CosmosClientOptions
{
    ApplicationPreferredRegions = [Regions.AustraliaEast, Regions.AustraliaSoutheast]
});
await database.CreateContainerIfNotExistsAsync(new ContainerProperties("profiles", "/customerId")
{
    ConflictResolutionPolicy = new ConflictResolutionPolicy
    {
        Mode = ConflictResolutionMode.LastWriterWins,
        ResolutionPath = "/updatedAtTicks"
    }
});

Last writer wins is fine for a customer profile, not for a balance. For money, give each account one home region for writes, so there is nothing to resolve.

Don't build one giant system. Build many small identical ones 🧱

That's cell-based architecture:

🧭 A thin router maps each customer to a cell

🧱 Each cell has its own app, database and cache

💥 A bad deploy or DB outage hits one cell (say 10% of customers), not everyone

📈 Need more capacity? Add a cell. Never run a size you haven't tested

Add multi-region active-active on top, and the hard part is no longer routing. It's capacity headroom and write conflicts.

#SystemDesign #Resilience #CloudArchitecture #SRE

⚡ Quick-fire Q&A

Your service has 3 dependencies at 99.9% each. What's your max availability?

About 99.7% (0.999³). To do better, add redundancy, caching or async decoupling.

When would you choose AP over CP?

When stale data is acceptable and downtime isn't: product catalogues, social feeds, recommendations, shopping carts.

Difference between HA and DR?

HA handles local failures (node or zone) automatically with little or no downtime. DR recovers from a large-scale disaster (region loss, ransomware) within the agreed RPO/RTO.

Peak is 600 requests/s and the average response time is 200 ms. How many requests are in flight?

Little's Law: L = λ × W = 600 × 0.2 = 120. If one instance handles 30 concurrently at a safe utilisation, you need 4, plus at least 1 spare for failures or a lost zone.

What is a cell-based architecture, and why use it?

Many independent copies of the whole stack, each serving a fixed slice of customers, behind a thin router. A failure or bad deployment only affects one cell's customers (smaller blast radius), and you scale by adding cells of a tested size.

Active-active across regions: what's the hardest part?

Not routing. It's data (async replication means possible loss or write conflicts, so pick a home region per record or a conflict policy) and capacity (each region must be able to take 100% of traffic after a failover).

← 02 · Networking & Protocols