Performance and Scalability Patterns

📖 15 min read

A system meets more load in one of three ways. It can add copies of the thing doing the work and spread requests across them, it can avoid doing the same work repeatedly, or it can split the data so no single store holds all of it. When none of those is enough, or while they catch up, it has to refuse some of the load deliberately so the rest still gets served.

Load balancing spreads requests, rate limiting decides what to refuse, caching avoids repeated work, and sharding splits the data. Sharding comes last because it is the most expensive of the four to undo.

Load Balancing

A load balancer sits in front of several instances of a service and distributes requests among them, so capacity grows by adding instances and a failed instance stops receiving traffic instead of failing requests.

Use when:

  • A service runs as several interchangeable instances
  • Capacity has to grow by adding instances rather than by buying a larger machine
  • An instance failing should reduce capacity, not cause errors

Choosing an Algorithm

Algorithm How it picks Suits Watch for
Round robin Each instance in turn Identical instances and similar requests Uneven load when requests vary widely in cost
Weighted round robin In turn, in proportion to assigned weights Instances of different sizes Weights going stale as instances change
Least connections The instance with the fewest open connections Long-lived connections or variable request duration Newly added instances getting flooded
Least response time The instance responding fastest recently Instances whose performance drifts Oscillation as traffic chases the fastest instance
Hash or sticky sessions The same client always goes to the same instance Servers holding per-client session state Hot instances, and lost sessions when an instance dies

Sticky sessions are usually a sign that session state should move out of the instance into a shared store, so that any instance can serve any request.

Layer 4 and Layer 7

A layer 4 balancer routes TCP or UDP connections by address and port without reading their contents. That makes it cheap and protocol-agnostic, but it balances connections rather than requests. When a client holds one long-lived connection and sends many requests over it, as HTTP/2 and gRPC clients do, every one of those requests lands on the same instance.

A layer 7 balancer terminates the connection and reads the application protocol, so it can route by path, header, or cookie, and balance individual requests. It pays for that in per-request processing and in being a point where TLS has to terminate.

Flow

Layer 4 and Layer 7 Balancing

One HTTP/2 connection, balanced as a connection or as requests.

Layer 4 balancing compared with layer 7 balancing for one HTTP/2 connection Two panels. A client holds one HTTP/2 connection to a load balancer. A layer 4 balancer balances connections, so all 500 requests on that connection go to instance A and instance B sits idle. A layer 7 balancer balances requests, so about 250 go to instance A and about 250 to instance B. LAYER 4: BALANCES CONNECTIONS Client one HTTP/2 connection LB Instance A Instance B all 500 requests idle LAYER 7: BALANCES REQUESTS Client one HTTP/2 connection LB Instance A Instance B ≈250 requests ≈250 requests

Common implementations: NGINX, HAProxy, Envoy, and cloud balancers such as AWS Application Load Balancer at layer 7 and Network Load Balancer at layer 4.

Trade-offs: A load balancer only helps a service whose instances are interchangeable, which pushes state out into databases and caches that then become the bottleneck. The balancer itself is on every request’s path and has to be redundant. And its health checks decide which instances are in rotation, so a health check that tests the wrong thing removes healthy instances or keeps broken ones.


Rate Limiting and Throttling

A rate limiter caps how many requests a client, key, or tenant can make in a period, and rejects or delays the rest. Throttling is the same mechanism applied from the server’s side to protect its own capacity. Both trade some rejected requests for keeping the service usable by everyone else.

Use when:

  • One client could consume capacity every other client depends on
  • A downstream dependency has a hard limit or a per-call cost
  • Usage tiers need enforcing, such as a free plan and a paid plan with different limits
  • Abusive or runaway clients need containing, alongside rather than instead of dedicated DDoS protection

Algorithms

Algorithm How it works Bursts Memory per client
Token bucket A bucket holds up to N tokens and refills at a steady rate, and each request spends one Allowed, up to the bucket size Two values
Leaky bucket Requests join a queue drained at a fixed rate, and overflow is rejected Smoothed out, at the cost of queueing delay A queue
Fixed window counter Count requests per calendar window, such as per minute Up to double the limit across a window boundary One counter
Sliding window log Keep a timestamp for every request and count those within the last window None beyond the limit One entry per request
Sliding window counter Weight the previous window’s count by how much of it still overlaps, and add the current count Approximately bounded Two counters

The fixed window’s boundary problem is the one that surprises people. A limit of 100 per minute allows 100 requests at 12:00:59 and another 100 at 12:01:00, so 200 arrive within two seconds.

Chart

Fixed Window Boundary Burst

Two full windows back to back let double the limit through.

A fixed window rate limit allowing a burst across a window boundary A timeline from 12:00:00 to 12:02:00 split into two one-minute windows with a limit of 100 requests each. 100 requests arrive at 12:00:59, at the end of the first window, and another 100 at 12:01:00, at the start of the second. Each window stays within its limit, yet 200 requests arrive within two seconds. Window 1 100 of 100 allowed Window 2 100 of 100 allowed 100 at 12:00:59 100 at 12:01:00 200 requests within two seconds 12:00:00 12:01:00 12:02:00 Limit: 100 requests per one-minute window

Token bucket is the common default for APIs because it permits short bursts while holding the long-run average, and it is what Amazon API Gateway throttling uses. The sliding window counter gets close to a log’s accuracy with a counter’s memory, and Cloudflare described using it to rate limit across its edge network.

Example: Each API key gets 1,000 requests an hour with bursts of up to 100. As a token bucket, that is a capacity of 100 tokens refilled at 10 tokens every 36 seconds. In ASP.NET Core, the built-in rate limiting middleware partitions that bucket per API key, which should already have been authenticated by this point.

builder.Services.AddRateLimiter(options =>
{
    options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;

    options.AddPolicy("per-api-key", httpContext =>
        RateLimitPartition.GetTokenBucketLimiter(
            partitionKey: httpContext.Request.Headers["X-API-Key"].ToString(),
            factory: _ => new TokenBucketRateLimiterOptions
            {
                TokenLimit = 100,                              // burst size
                TokensPerPeriod = 10,
                ReplenishmentPeriod = TimeSpan.FromSeconds(36), // 1,000 per hour
                QueueLimit = 0
            }));
});

app.UseRateLimiter();
app.MapGet("/orders", GetOrders).RequireRateLimiting("per-api-key");

A rejected client should get 429 Too Many Requests with a Retry-After header saying when a token will next be available, which for this bucket is at most 36 seconds, not the hour the quota is expressed in.

Trade-offs: A limiter that runs on each instance separately enforces its limit per instance, so ten instances allow ten times the intended rate. Enforcing a global limit needs a shared counter, typically in Redis, which adds a network call to every request and a dependency to fail. Partitioning by a value the client controls, such as an unauthenticated API key header or a spoofable IP address, lets an attacker create unlimited partitions, each with a fresh bucket. And limits set too low turn legitimate traffic spikes into errors, so they need to come from measured usage rather than guesses.


Caching

A cache keeps a copy of data somewhere faster to read than its source, trading freshness and memory for fewer trips to the database. The patterns differ in which component is responsible for loading the cache and keeping it current.

Flow

Cache-Aside and Read-Through

Who talks to the database: the application, or the cache.

Cache-aside compared with read-through and write-through caching Two panels. In cache-aside, the application first gets from the cache, and on a miss it queries the database and then sets the value in the cache, so the application owns both calls. In read-through and write-through, the application only gets from and sets to the cache, and the cache loads from and stores to the database, so the cache owns the database access. CACHE-ASIDE App Cache DB 1. get miss 2. query 3. set The app owns both calls. READ-THROUGH / WRITE-THROUGH App Cache DB get / set load / store The cache owns the database access.

Cache-Aside

The application checks the cache, and on a miss it reads the database and populates the cache itself. On an update, it writes the database and invalidates the cache entry. The cache knows nothing about the database.

GET /users/123
  1. value = cache.get("user:123")
  2. if miss: value = db.query(...); cache.set("user:123", value, ttl = 10 min)
  3. return value

PUT /users/123
  1. db.update(...)
  2. cache.delete("user:123")

Use when:

  • Reads far outnumber writes, and the same keys are read repeatedly
  • Only a fraction of the data is hot, so caching everything would waste memory
  • The application needs control over what is cached and for how long

Read-Through and Write-Through

Here the cache sits in front of the database and handles loading itself. With read-through, a miss makes the cache provider fetch the value from the source through a loader the application registers, so application code only ever calls the cache. With write-through, writes go to the cache, which writes the database synchronously before returning. Write-behind is the asynchronous variant, where the cache acknowledges the write and persists it later.

Use when:

  • The caching logic is repeated across many call sites and belongs in one place
  • The cache product supports loaders natively, as many distributed caches and caching libraries do
  • With write-behind, write throughput matters more than guaranteeing every write reached the database before responding

Invalidation and Stampedes

Both patterns share the hard parts. A TTL bounds how stale an entry can get but guarantees it will sometimes be stale. Deleting on update is fresher but has a race: a reader that fetched the old value just before the update can write it back into the cache just after the delete. Short TTLs are the usual backstop for that race.

A cache stampede happens when a popular entry expires and every concurrent request misses at once, sending them all to the database together. The mitigations are to let only one caller per key recompute while the rest wait for its result, to refresh popular entries before they expire, or to add random jitter to TTLs so entries written together don’t all expire together.

Trade-offs: A cache is a second copy of the data, and every second copy can disagree with the first. It also changes the system’s failure behavior. A database sized for the load that reaches it through a warm cache can’t survive a cold one, so a cache restart or a mass expiry becomes a database outage. Write-behind adds the possibility of losing writes the client was told had succeeded.


Sharding

Sharding splits one database’s data across several databases, each holding a subset chosen by a shard key. Every query is routed to the shard that holds its key. It is how a data store grows past the limits of one machine, and it is the pattern here that is hardest to reverse.

Structure

Sharding by Key Range

One overloaded database split into three shards, with queries routed by key.

A database before and after sharding by username range Before sharding, a single overloaded database holds 100 million users. After sharding, three shards hold users A to H, I to P, and Q to Z. The application routes a query for the username john_doe to shard 1, which holds I to P, and queries only that shard. BEFORE SHARDING Single database 100M users overloaded AFTER SHARDING Application routeToShard("john_doe") → Shard 1 (I-P) Shard 0 users A-H Shard 1 users I-P Shard 2 users Q-Z query by username

Use when:

  • Write volume exceeds what one database server can sustain
  • The data set no longer fits on one server
  • Read replicas, caching, query tuning, and a larger server have all been tried or ruled out

Read replicas scale reads without any of the costs below, and a larger server is almost always cheaper than sharding to operate. Sharding earns its cost when writes or storage, not reads, are what outgrew the machine.

Shard Key Strategies

Strategy How it works Strength Weakness
Range-based Contiguous key ranges, such as A-H, I-P, Q-Z Range queries stay on one shard Uneven distribution, and sequential keys pile onto the last shard
Hash-based hash(key) % shard_count Even distribution Range queries hit every shard, and changing the shard count moves most keys
Directory-based A lookup table maps each key or tenant to a shard Any placement you like, including moving one hot key Every query needs the lookup, and the directory must be highly available
Range by creation date:
  Shard 1: older orders
  Shard 2: this year's orders
  Every new order lands on Shard 2, so it takes all the write load

Hash by user id:
  hash(user_id) = 7834 → 7834 % 3 = 1 → Shard 1
  hash(user_id) = 2942 → 2942 % 3 = 2 → Shard 2
  Writes spread evenly, but "orders placed last month" must ask all three

Hot Spots

A hot spot is one shard taking a disproportionate share of traffic, usually because of a single very active key such as a celebrity account, a large tenant, or a viral item. An even hash can’t prevent this, because it spreads keys evenly, not the traffic on each key.

Mitigations run from simple to invasive: cache the hot key’s reads in front of the shard, move a known hot key to a dedicated shard through a directory, or split the key itself by appending a suffix (user_123#0 through user_123#9) so its writes spread across shards, at the cost of every read of that key gathering from all of them.

Resharding and Consistent Hashing

With hash(key) % N, changing N remaps most keys. Going from 3 shards to 4 leaves a key in place only when hash % 3 equals hash % 4, which holds for a quarter of keys, so three quarters of the data has to move.

hash = 7834:  % 3 = 1,  % 4 = 2   → moves
hash = 2942:  % 3 = 2,  % 4 = 2   → stays
hash = 5310:  % 3 = 0,  % 4 = 2   → moves

Consistent hashing places both shards and keys on a ring of hash values, and each key belongs to the first shard found moving clockwise from it. Adding a shard claims only the arc of the ring just before it, taking keys from the one shard that previously owned that arc and leaving every other key where it was. Going from 3 shards to 4 moves roughly a quarter of the keys instead of three quarters.

Dependency graph

Consistent Hashing Ring

A new shard takes keys from one neighbor and leaves the rest in place.

Adding a shard to a consistent hashing ring A ring of hash values with shards A at the top, D on the right, B at the bottom, and C on the left. Each key belongs to the first shard found moving clockwise from it. Shard D is new. It takes only the keys on the arc between A and D, which previously belonged to B. Keys on every other arc stay where they were. clockwise A D B C new shard A key belongs to the first shard found moving clockwise from it. Keys between A and D moved from B to D, the only keys that moved. Every other key stays where it was. Going from 3 shards to 4 moves roughly a quarter of the keys, not three quarters.

In practice each physical shard is placed at many points on the ring, called virtual nodes, which evens out how much of the ring each shard owns.

Cross-Shard Queries

Any query that isn’t keyed to one shard has to fan out to all of them in parallel and combine the answers in the application. Counts and sums combine cheaply. Sorting and paging don’t: a request for the top 20 results has to fetch the top 20 from every shard and re-sort them, and a request for page 50 has to fetch the first 1,000 from each. Joins across shards generally have to be rebuilt in application code or avoided by design.

Choose the Shard Key Carefully

The shard key decides which queries stay cheap for the life of the system, and changing it later means moving all of the data. Choose it from the queries the system runs most, so that they stay on a single shard, and exhaust replicas, caching, and vertical scaling before sharding at all.

Trade-offs: Transactions and joins that used to be local now span databases. Operations multiply, since backups, schema migrations, and failovers happen once per shard. Rebalancing is a live data migration. And every query that the shard key doesn’t serve gets slower, not faster.


Quick Reference

Pattern What it scales or protects Reach for it when Main cost
Load balancing Request throughput, across stateless instances One instance isn’t enough, or shouldn’t be a single point of failure State pushed out to shared stores, and a balancer on every path
Rate limiting Shared capacity, against any one client One client could starve the rest, or a dependency has a hard limit Rejected requests, and a shared counter for global limits
Cache-aside Read throughput for hot keys Reads dominate and a fraction of keys are hot Staleness, invalidation races, and a database that can’t survive a cold cache
Read-through / write-through The same, with loading centralized in the cache Caching logic is repeated across call sites Dependence on cache product features, and lost writes with write-behind
Sharding Write throughput and data volume beyond one server Writes or storage have outgrown vertical scaling and replicas Cross-shard queries, per-shard operations, and a shard key that is hard to change

Found this guide helpful? Share it with your team:

Share on LinkedIn