Section 21

Design case studies

Seven classic system design questions, each worked through the same way: requirements, estimates, API, data model, an animated high-level design, deep dives, bottlenecks, and the .NET 10 stack you'd build it with.

How to use them: read the question line only, set a 45-minute timer, and answer it yourself with the 7-step framework from 20. Say the numbers and trade-offs out loud. Then compare with the write-up and note what you missed. Each one also ends with a LinkedIn post you can reuse.

21.1 URL shortener Added

"Design a service like bit.ly that turns a long URL into a short code and redirects anyone who opens it." A read-heavy key-value lookup with one hard part: making unique short keys.

1. Requirements

  • F: create a short link (optional custom alias and expiry), redirect, see click counts
  • NFR: 100M new links/month, 100:1 read:write, redirect p99 < 50 ms, 99.99% available, links never collide, codes not easy to guess
  • Out of scope: user accounts UI, link previews

2. Estimates

  • Writes: 100M ÷ (30 × 86,400 s ≈ 2.6M s) ≈ 40/s
  • Reads: 40 × 100 = 4,000/s average, ~10,000/s at peak
  • Storage: 100M × 12 × 5 years = 6B links × 500 B ≈ 3 TB
  • Key length: 626 ≈ 57B, 627 ≈ 3.5 trillion, so 7 base62 characters is plenty
  • Bandwidth: 4,000 × 500 B ≈ 2 MB/s (tiny). Cache the hot 20% of links: a few GB to tens of GB

3. API

EndpointDoes
POST /api/links {longUrl, alias?, expiresAt?}201 + {code, shortUrl}
GET /{code}302 to the long URL (404 or 410 if gone)
GET /api/links/{code}/statsClick counts by day, country, referrer
DELETE /api/links/{code}Owner disables the link

4. Data model

TableKeyColumns
linkscode (PK)long_url, owner_id, created_at, expires_at
clicks_daily(code, day)count, top countries / referrers

One lookup by key, no joins. A key-value or NoSQL store (Cosmos DB, DynamoDB, Cassandra) fits; SQL is also fine at 3 TB if you shard by code.

5. High-level design

6. Deep dives

Key generationHowGoodWatch out
Hash the URLMD5/SHA-256 of the long URL, take the first 7 base62 charsSame URL gives the same code; no coordinationCollisions: check and retry with a salt. Extra DB read on every write
Counter + base62Unique number (DB sequence or Snowflake-style ID), encode in base62No collisions, short codesSequential codes are guessable; one counter is a hotspot. Shuffle or encrypt the number first
Pre-generated keys (KGS)A key service creates random codes offline. Each API node grabs a block of 1,000 into memoryNo collisions, fast, codes look randomKeys in a crashed node's block are lost (fine: 3.5 trillion exist). The KGS needs a replica

301 or 302?

  • 301 (permanent): browsers cache it and skip you next time. Less load, but you lose click analytics and can't change or expire the link.
  • 302 (or 307, temporary): every click reaches you. Use it when analytics, expiry or abuse blocking matter. Use a CDN cache with a short TTL to take the load.

Read-heavy caching and async analytics

  • Cache-aside in Redis with LRU eviction. A small in-process L1 cache for the hottest links (05 strategies).
  • Cache "not found" for a short time too, so bots scanning random codes don't hit the DB.
  • Emit a click event to a queue or stream. A consumer aggregates counts per day. The redirect never waits on analytics.

7. Bottlenecks & scaling

  • Do the key-space maths first: 7 base62 characters ≈ 3.5 trillion codes.
  • Name the three key options (hash, counter + base62, KGS) and pick one with a reason.
  • 302 when you need analytics or expiry; 301 when you only want to save load.
  • Analytics are async. The redirect path does one cache lookup and nothing else.
The .NET stack for this design
ComponentLibrary / APINotes
API + redirectbuilt-in ASP.NET Core minimal APIs, Results.Redirect(url, permanent: false)permanent: false returns 302, true returns 301
CacheMicrosoft.Extensions.Caching.Hybrid + Microsoft.Extensions.Caching.StackExchangeRedisL1 in-process + Redis L2, with stampede protection built in
StoreMicrosoft.Azure.Cosmos (partition key /code, TTL for expiry) or EF Core 10 on SQLPoint reads by key
Short codesYour own base62 encoder; IdGen for Snowflake-style IDs; Sqids to make numbers look randomSqids hides order but is not encryption
Click eventsAzure.Messaging.EventHubs or Confluent.KafkaFire-and-forget after the redirect; aggregate in a consumer
Creation limitsbuilt-in Microsoft.AspNetCore.RateLimitingPer user / IP on POST /api/links
const string Alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";

static string ToBase62(long n)                   // 125 => "21" (2×62 + 1)
{
    if (n == 0) return "0";
    Span<char> buf = stackalloc char[11];        // long.MaxValue fits in 11 chars
    int i = buf.Length;
    while (n > 0) { buf[--i] = Alphabet[(int)(n % 62)]; n /= 62; }
    return new string(buf[i..]);
}

app.MapGet("/{code}", async (string code, HybridCache cache, LinksDb db, CancellationToken ct) =>
{
    var url = await cache.GetOrCreateAsync($"link:{code}",
        async token => await db.Links.Where(l => l.Code == code)
                                     .Select(l => l.LongUrl).FirstOrDefaultAsync(token),
        cancellationToken: ct);
    return url is null ? Results.NotFound() : Results.Redirect(url, permanent: false);  // 302
});

Only redirect to URLs you validated at creation time (http/https only), or the shortener becomes an open-redirect tool for phishing.

"Design a URL shortener" looks easy. The interview is really about one question: how do you make the key? 🔗

🧮 Do the maths: 7 base62 characters ≈ 3.5 trillion codes

#️⃣ Hash the URL: simple, but you must handle collisions

🔢 Counter + base62: no collisions, but guessable unless you shuffle it

🗝️ Pre-generated key blocks: fast, random-looking, a little waste

↪️ 302 if you want analytics, 301 if you only want less load

The redirect path should be one cache lookup. Everything else is async.

Which key strategy would you pick? #SystemDesign #Architecture #InterviewPrep #DotNet

21.2 Distributed rate limiter Added

"Design a rate limiter that every API gateway node shares, so a client gets the same limit no matter which node it hits." The algorithms are in 08.6 Rate limit algorithms; this is the system around them.

1. Requirements

  • F: limit per API key, user, IP or tenant; different limits per plan and endpoint; tell clients their remaining quota
  • NFR: 1M requests/s at peak across all gateways, adds < 2 ms p99, accurate within a few %, keeps working if the limiter store fails
  • Out of scope: monthly billing quotas (a separate, slower counter)

2. Estimates

  • Checks: 1 check per request = 1M Redis calls/s
  • Redis: one shard handles roughly 100k simple ops/s, so ~10 shards, ~20 with headroom
  • Memory: 10M active keys × ~100 B per bucket ≈ 1 GB (fits easily)
  • Bandwidth: 1M × ~200 B ≈ 200 MB/s between gateways and Redis: keep them in the same zone

3. API

  • Mostly an internal call: Check(key, policy, cost=1) → {allowed, remaining, retryAfter}
  • Admin: PUT /policies/{plan} {limit, window, burst}
  • Response headers to clients (IETF draft, not yet an RFC):
HTTP/1.1 429 Too Many Requests
Retry-After: 1
RateLimit-Policy: "default";q=100;w=10
RateLimit: "default";r=0;t=1

4. Data model

WhereKeyValue
Redis hashrl:{policy}:{clientId}tokens, last_refill_ts; TTL ≈ 2 × refill time
Policy tableplan + routecapacity, refill rate, cost; cached in every gateway

5. High-level design

6. Deep dives

Algorithm choice

  • Token bucket for APIs: allows short bursts, two numbers to tune.
  • Sliding window counter when you need even limits per minute with little memory.
  • Avoid fixed windows for strict limits (2× burst at the edges).

Atomic check with Lua

  • "Read tokens, then write tokens" from two gateways is a race: both see 1 token, both allow.
  • A Lua script runs inside Redis as one atomic step: refill, take, save, return.
  • Use Redis server time (TIME) so gateway clock skew doesn't matter.

Per-key partitioning

  • Redis Cluster shards by key hash, so load spreads across shards automatically.
  • One bucket = one key, so the script never needs keys from two shards.
  • Hot key (one huge tenant)? Split its quota into N sub-buckets, or give it a local limiter.

Multi-region

  • Per-region limits (limit ÷ regions): simple, no cross-region calls. Most common.
  • Global: active-active Redis with CRDT counters replicates async, so it can overshoot briefly.
  • Never do a synchronous cross-region call per request (adds 50–200 ms).

Fail-open or fail-closed?

  • Fail-open (allow if Redis is down): protects availability. Right for most APIs, with a local in-memory limiter as a safety net.
  • Fail-closed (deny): for login, OTP and payment-initiation endpoints where abuse costs more than downtime.
  • Short timeout (~5 ms) on the Redis call either way.

Where it lives

  • Edge (WAF/CDN) for IP floods, gateway (APIM, YARP) for keys and plans, service for business limits.
  • Limit only after authentication, so attackers can't invent keys to get new buckets.

7. Bottlenecks & scaling

BottleneckFix
Redis round trip on every requestSame-zone Redis, pipelining, EVALSHA (script cached by hash); or a local token cache that syncs with Redis every 100 ms (less exact)
One shard overloadedMore shards; split hot tenants into sub-buckets
Redis outageFail-open + local limiter; replica with automatic failover
Policy changesPush policies to gateways (config refresh), never read them per request
  • Built-in .NET limiters count per instance. A shared limit needs a shared store.
  • Check and update in one atomic step (Lua script), with Redis server time.
  • Decide fail-open vs fail-closed per endpoint, and say why.
  • Return 429 + Retry-After; clients back off with jitter.
The .NET stack for this design
ComponentLibrary / APINotes
GatewayYarp.ReverseProxy (or Azure API Management)Run the limiter as middleware in front of the proxy
Local limiter / fallbackbuilt-in System.Threading.RateLimiting (PartitionedRateLimiter, TokenBucketRateLimiter)Per-node safety net when Redis is unreachable
Shared bucketStackExchange.Redis ScriptEvaluateAsync with your Lua scriptOr RedisRateLimiting.AspNetCore (community) for ready-made Redis limiters
PoliciesMicrosoft.Extensions.Options + Azure App Configuration (Microsoft.Extensions.Configuration.AzureAppConfiguration)Refresh limits without a redeploy
ObservabilityOpenTelemetry metrics (OpenTelemetry.Extensions.Hosting)Count allowed / denied / fail-open per policy
const string TokenBucketLua = """
    local cap, rate = tonumber(ARGV[1]), tonumber(ARGV[2])          -- capacity, tokens/sec
    local t = redis.call('TIME'); local now = t[1] + t[2] / 1000000  -- Redis clock, not ours
    local b = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
    local tokens = math.min(cap, (tonumber(b[1]) or cap) + (now - (tonumber(b[2]) or now)) * rate)
    local ok = 0
    if tokens >= 1 then tokens = tokens - 1; ok = 1 end
    redis.call('HSET', KEYS[1], 'tokens', tokens, 'ts', now)
    redis.call('EXPIRE', KEYS[1], math.ceil(cap / rate) * 2)
    return { ok, math.floor(tokens) }
    """;

app.Use(async (ctx, next) =>
{
    var client = ctx.User.FindFirst("client_id")?.Value ?? $"ip:{ctx.Connection.RemoteIpAddress}";
    RedisResult[] r;
    try { r = (RedisResult[])(await redis.ScriptEvaluateAsync(TokenBucketLua, [new RedisKey($"rl:default:{client}")], [100, 10]))!; }
    catch (Exception e) when (e is RedisException or TimeoutException) { await next(ctx); return; } // fail open

    ctx.Response.Headers["RateLimit-Policy"] = "\"default\";q=100;w=10";
    ctx.Response.Headers["RateLimit"] = $"\"default\";r={(int)r[1]}";
    if ((int)r[0] == 0) { ctx.Response.StatusCode = 429; ctx.Response.Headers.RetryAfter = "1"; return; }
    await next(ctx);
});

The RateLimit / RateLimit-Policy headers are an IETF draft (draft-ietf-httpapi-ratelimit-headers), not an RFC yet. Many APIs still send the older RateLimit-Limit/Remaining/Reset or X-RateLimit-* names. Retry-After is the standard one.

Your rate limiter works on one server. Then you scale to 10 nodes and every client gets 10× the limit. 😬

🪣 Token bucket per client, stored in Redis, shared by all nodes

⚛️ Check + update in ONE Lua script, or two nodes both spend the last token

🕐 Use Redis server time, not each node's clock

🌏 Multi-region: split the limit per region, don't call across regions

🔓 Redis down? Fail open for normal APIs, fail closed for login and OTP

And always return 429 + Retry-After so clients can back off politely.

#SystemDesign #APIs #RateLimiting #Redis #DotNet

21.3 Chat / messaging app Added

"Design WhatsApp or Teams chat: 1:1 and group messages, online status, delivered and read ticks, and nothing lost when you're offline."

1. Requirements

  • F: 1:1 and group chat (up to 500 members), presence, delivered/read receipts, history on any device, push when offline
  • NFR: 50M daily users, 10M connected at once, delivery p99 < 500 ms when online, messages never lost, ordered within a conversation
  • Out of scope: voice/video calls, end-to-end encryption details (mention it)

2. Estimates

  • Messages: 50M × 40/day = 2B/day ÷ 86,400 ≈ 23k/s, ~70k/s at peak
  • Storage: 2B × 200 B ≈ 400 GB/day ≈ 146 TB/year before replication
  • Connections: 10M ÷ ~50k per gateway server ≈ 200 servers (plus headroom)
  • Bandwidth: 23k × 200 B ≈ 5 MB/s in; out is higher because groups fan out

3. API

CallDoes
WebSocket send {convId, clientMsgId, body}Server replies ack {clientMsgId, seq}
WebSocket push message, receipt, presenceServer → client events
GET /conversations/{id}/messages?before={seq}&limit=50Scroll back through history
GET /sync?since={lastSeq}Catch up after reconnecting

4. Data model

TablePartition keyClustering / columns
messages (wide-column)(conv_id, month bucket)seq DESC; sender, body, sent_at
conversation_membersconv_iduser_id, role, last_read_seq
user_inboxuser_idconv_id, last_seq, unread count
sessions (Redis)user_idgateway node, devices, last_seen (TTL)

5. High-level design

6. Deep dives

Connections

  • WebSockets: one long-lived, two-way connection per device (02 real-time).
  • Gateways hold connections only; chat logic sits behind them, so gateways can restart safely.
  • Clients reconnect with backoff and call /sync for anything missed.

Ordering per conversation

  • A per-conversation sequence number (Redis INCR, or one partition per conversation in Kafka).
  • Global order is not needed and doesn't scale. Order within a chat is enough.
  • Clients sort by seq and spot gaps, then fetch the missing range.

Group delivery

  • Small groups: fan out on write, one send per online member's gateway.
  • Big channels (thousands): store once, notify "new message in X", clients pull.
  • Gateways subscribe to a pub/sub backplane (Redis, Azure SignalR) to reach each other.

Storage: wide-column

  • Write-heavy, read by "conversation, newest first": a perfect fit for Cassandra/ScyllaDB or Cosmos DB.
  • Bucket by month so one busy chat doesn't create a huge partition.
  • Discord publicly described storing trillions of messages this way (Cassandra, later ScyllaDB).

Presence, offline, receipts

  • Presence: heartbeat every ~30 s sets a Redis key with a TTL. No heartbeat, key expires, user is offline.
  • Only push presence to people who have the chat open, or it floods the system.
  • Receipts store "read up to seq N" per member, not a flag on every message.

Never lose, never duplicate

  • Persist before you ack. The sender's single tick means "stored".
  • The client resends until acked, so dedupe on clientMsgId.
  • Push (APNs/FCM) is a wake-up hint only; the app then syncs from the server.

7. Bottlenecks & scaling

BottleneckFix
Millions of open socketsMany small gateway nodes behind an L4 load balancer; or a managed service (Azure SignalR / Web PubSub)
Finding which node a user is onSession map in Redis; or pub/sub channel per user
Reconnect storm after a deployDrain nodes slowly, random jitter on client reconnect
Hot conversation partitionTime buckets in the partition key; cache recent messages in Redis
  • Store first, then ack, then deliver. Push notifications only wake the app.
  • Order per conversation with a sequence number; never promise global order.
  • Presence = heartbeat + TTL. Receipts = "read up to N".
The .NET stack for this design
ComponentLibrary / APINotes
Real-time connectionsbuilt-in ASP.NET Core SignalR (Hub, groups, Clients.User)WebSockets with fallbacks; Context.UserIdentifier from the JWT
Scale-outMicrosoft.Azure.SignalR (Azure SignalR Service) or Microsoft.AspNetCore.SignalR.StackExchangeRedis backplaneManaged service holds the sockets so your servers don't have to
Sequence, presence, sessionsStackExchange.RedisStringIncrementAsync for seq; keys with TTL for presence
Message storeCassandraCSharpDriver (Cassandra / ScyllaDB) or Microsoft.Azure.CosmosPartition by conversation + month
Offline pushMicrosoft.Azure.NotificationHubs or FirebaseAdminAPNs / FCM wake-up
TracingOpenTelemetryMeasure "sent → delivered" per message
[Authorize]
public sealed class ChatHub(IConnectionMultiplexer redis, IMessageStore store) : Hub
{
    public async Task<long> Send(string convId, Guid clientMsgId, string body)
    {
        // TODO: check the caller is a member of convId
        var db = redis.GetDatabase();
        long seq = await db.StringIncrementAsync($"seq:{convId}");          // order within the chat
        await store.AppendAsync(new ChatMessage(convId, seq, Context.UserIdentifier!, clientMsgId, body));
        await Clients.OthersInGroup(convId).SendAsync("message", convId, seq, body);
        return seq;                                                         // the ack: one tick
    }

    public Task Join(string convId) => Groups.AddToGroupAsync(Context.ConnectionId, convId);
}

builder.Services.AddSignalR().AddAzureSignalR();   // or .AddStackExchangeRedis(connString)

AppendAsync should be idempotent on clientMsgId (a resend after a lost ack must not create a second message). If INCR succeeds but the store fails, you get a gap in seq; clients must tolerate gaps.

How does a chat app show ✓ then ✓✓ then blue ticks? 💬

🔌 Each phone keeps one WebSocket open to a gateway

💾 The server stores the message BEFORE it says "got it" (✓)

🔢 Each conversation has its own sequence number, so order is per chat

📬 Bob's phone confirms delivery (✓✓) and later "read up to #1042"

😴 Offline? A push wakes the app, and it syncs from the server

Store first, ack second, deliver third. That one rule removes most chat bugs.

#SystemDesign #RealTime #WebSockets #SignalR #DotNet

21.4 Notification service (multi-channel platform) Added

"Design a shared platform that any team can call to send email, SMS, push and in-app messages, respecting user preferences." Unlike the 20 payment-alert example (one event source, one use case), this is the generic engine behind many products.

1. Requirements

  • F: one API for all channels; templates with variables and languages; user preferences and quiet hours; scheduling; delivery status
  • NFR: 10M users, 100M notifications/day; OTPs and security alerts in < 5 s, marketing within hours; no duplicates; survive provider outages
  • Out of scope: the campaign-design UI

2. Estimates

  • Average: 100M ÷ 86,400 ≈ 1,200/s
  • Peak: a campaign to all 10M users in 15 min ≈ 11k/s on top
  • Storage: 100M × 1 KB ≈ 100 GB/day; keep 30 days hot ≈ 3 TB
  • Provider limits: if an SMS account allows e.g. 100 msg/s, 1M SMS takes ~2.8 hours. Queues absorb it

3. API

POST /v1/notifications
Idempotency-Key: 7c1e...           (caller's unique ID)
{ "userId": "u42", "template": "card-locked",
  "priority": "high", "data": { "last4": "1234" },
  "channels": ["push", "sms"] }      (optional: else use prefs)
=> 202 { "notificationId": "n-981" }

GET  /v1/notifications/{id}         status per channel
PUT  /v1/users/{id}/preferences      opt-ins, quiet hours
POST /v1/providers/{name}/callbacks  delivery receipts (webhooks)

4. Data model

TableKeyColumns
templates(name, channel, locale, version)subject, body with placeholders
preferencesuser_idper category × channel opt-in, quiet hours, time zone
notificationsnotification_id; unique idempotency_keyuser, template, priority, created_at
deliveries(notification_id, channel)status, provider, provider_msg_id, attempts

5. High-level design

6. Deep dives

Priority

  • Separate queues and worker pools per priority. Don't rely on "priority" fields inside one queue.
  • Reserve provider capacity for high priority (e.g. 30% of the SMS rate).
  • Time-sensitive items get a TTL: an OTP that is 10 minutes late is useless, so drop it.

Retries, DLQ, idempotency

  • Retry transient errors (timeouts, 429, 5xx) with exponential backoff + jitter. Don't retry hard bounces.
  • DLQ after max attempts; replay after the fix.
  • Dedupe at two levels: the API's Idempotency-Key, and (notificationId, channel) before calling the provider.

Provider rate limits and failover

  • A token bucket per provider account in each worker (or a shared Redis bucket).
  • Circuit breaker per provider; on open, switch SMS to the backup provider.
  • Respect the provider's 429 and Retry-After.

Templates and preferences

  • Versioned templates per channel and locale; render in the router, not in the caller.
  • Categories: transactional/security (can't fully opt out) vs marketing (opt-in, must honour unsubscribe under spam laws such as Australia's Spam Act 2003).
  • Quiet hours use the user's time zone.

7. Bottlenecks & scaling

BottleneckFix
Campaign spikeQueues level the load; workers autoscale on queue depth (KEDA); low-priority queue drains at the provider's pace
Provider throughputMultiple accounts / providers; batch email APIs
Preference lookupsHybridCache in the router; invalidate on change
Status writes from webhooksWebhook endpoint just enqueues; a consumer updates deliveries in batches
  • Separate queues per channel and per priority, so marketing never blocks an OTP.
  • At-least-once + dedupe by (notificationId, channel). Retries only for transient errors.
  • Rate-limit yourself to each provider's limit, with a backup provider behind a circuit breaker.
The .NET stack for this design
ComponentLibrary / APINotes
APIbuilt-in minimal APIs, AddValidation(), EF Core 10 (unique index on idempotency key)Return 202 and enqueue
QueuesAzure.Messaging.ServiceBus (one queue per channel × priority; scheduled messages for quiet hours; built-in DLQ)Workers as BackgroundService, scaled by KEDA on Container Apps / AKS
Provider limitsbuilt-in System.Threading.RateLimiting TokenBucketRateLimiterOne limiter per provider account
Retries / failoverMicrosoft.Extensions.Http.Resilience / Polly.CoreBackoff + jitter, circuit breaker per provider
ChannelsAzure.Communication.Email, Azure.Communication.Sms, Microsoft.Azure.NotificationHubs, SignalR for in-appOne sender per channel behind an interface
Templates / prefsScriban or Fluid.Core (Liquid templates); HybridCache for prefsTemplates stored and versioned in the DB
// One limiter per SMS provider account: 100 msg/s, short queue for bursts
var smsQuota = new TokenBucketRateLimiter(new TokenBucketRateLimiterOptions
{
    TokenLimit = 100, TokensPerPeriod = 100, ReplenishmentPeriod = TimeSpan.FromSeconds(1),
    QueueLimit = 1_000, QueueProcessingOrder = QueueProcessingOrder.OldestFirst
});

// Separate processors for "sms-high" and "sms-low"; high gets more MaxConcurrentCalls
processor.ProcessMessageAsync += async args =>
{
    var job = args.Message.Body.ToObjectFromJson<SmsJob>()!;
    using RateLimitLease lease = await smsQuota.AcquireAsync(1, args.CancellationToken);
    if (!lease.IsAcquired) { await args.AbandonMessageAsync(args.Message); return; }   // try again later

    var sent = await sms.SendAsync(job.From, job.To, job.Text, cancellationToken: args.CancellationToken);
    await tracking.MarkSentAsync(job.NotificationId, "sms", sent.Value.MessageId);
    await args.CompleteMessageAsync(args.Message);
};
// An exception means the message is redelivered; after MaxDeliveryCount it moves to the DLQ

Set AutoCompleteMessages = false on the processor when you complete messages yourself. A limiter inside one worker only limits that worker: with 5 replicas, give each 1/5 of the provider rate or use a shared Redis bucket (21.2).

Why did my OTP arrive 40 minutes late? Because it was stuck behind a marketing campaign. 📵

A good notification platform avoids that:

🚦 Separate queues per channel AND per priority

🪣 Rate-limit yourself to each provider's limit (they will throttle you anyway)

🔁 Retry only transient errors, with backoff, then dead-letter

🧾 Dedupe on (notification, channel) so nobody gets the SMS twice

⌛ Give OTPs a short TTL: late is the same as never

One platform, many teams, zero "sorry for the spam" emails.

#SystemDesign #Messaging #Notifications #Architecture #DotNet

21.5 News feed / timeline Added

"Design the home feed for a social app: when I open it, show recent posts from people I follow, ranked, fast." The core decision is when to do the work: when a post is written, or when the feed is read.

1. Requirements

  • F: post, follow, home feed (followed accounts, ranked), infinite scroll
  • NFR: 200M daily users, feed load p99 < 300 ms, a new post shows up for followers within ~10 s, eventual consistency is fine
  • Out of scope: ads, search, media processing

2. Estimates

  • Posts: 200M × 0.5/day = 100M/day ≈ 1,200/s
  • Feed reads: 200M × 10/day = 2B/day ≈ 23k/s, ~50k/s at peak
  • Fan-out on write: 100M posts × 200 followers avg = 20B feed inserts/day ≈ 230k/s
  • Feed cache: 200M users × 500 entries × 16 B (id + score) ≈ 1.6 TB raw, so cache only active users and cap each feed

3. API

EndpointDoes
POST /postsCreate a post; emits PostCreated
GET /feed?limit=20&cursor=…Returns posts + nextCursor
PUT /users/{id}/followFollow / unfollow

4. Data model

StoreKeyValue
posts (NoSQL / sharded SQL)post_id (time-ordered)author, text, media refs, created_at
followsfollower_id → followee_id (and the reverse index)created_at
feed:{userId} (Redis sorted set)usermember = post_id, score = time; capped at ~800

5. High-level design

6. Deep dives

ApproachHowGoodBad
Fan-out on write (push)On post, insert its ID into every follower's feedReads are one cache lookup: very fastA user with 10M followers = 10M writes per post; wasted work for inactive followers
Fan-out on read (pull)On read, fetch recent posts of everyone I follow and mergeWrites are cheap; always freshSlow reads for users following thousands; heavy DB load
HybridPush for normal authors; pull for celebrities (above a follower threshold); merge at read timeFast reads, bounded write costTwo code paths; threshold to tune

Ranking

  • v1: reverse time order.
  • v2: score = f(recency, affinity with author, engagement, content type).
  • Rank a candidate set (a few hundred) at read time, then cache the ranked page briefly.

Caching in Redis

  • Feed = sorted set of post IDs only; post bodies are cached separately and shared by everyone.
  • Trim to the newest ~800 entries; older pages fall back to pull.
  • Inactive users get no fan-out; rebuild their feed on first visit.

Cursor pagination

  • Cursor = last seen (score, post_id), encoded opaque.
  • Offsets break when new posts arrive (you see duplicates or skip items).
  • Same idea as continuation tokens in Cosmos DB.

7. Bottlenecks & scaling

  • Say push vs pull vs hybrid, then the celebrity threshold. That's the core of the answer.
  • Feeds store IDs; posts are hydrated from a shared cache.
  • Cursor pagination, never offsets, for a feed that changes while you scroll.
The .NET stack for this design
ComponentLibrary / APINotes
Post + feed APIsbuilt-in minimal APIs; output caching for short-lived ranked pagesOpaque cursor as a Base64Url string
PostCreated streamConfluent.Kafka or Azure.Messaging.EventHubs.ProcessorPartition by author; fan-out workers as BackgroundService
Feed cacheStackExchange.Redis sorted sets (SortedSetAddAsync, SortedSetRemoveRangeByRankAsync)Batch / pipeline writes per follower block
Posts and followsMicrosoft.Azure.Cosmos or CassandraCSharpDriver; IdGen for time-ordered IDsFollower list read in pages
Post bodies cacheHybridCacheShared by all readers
public async Task FanOutAsync(Post post, CancellationToken ct)
{
    if (await users.GetFollowerCountAsync(post.AuthorId, ct) > CelebrityThreshold)
        return;                                         // celebrities: readers pull instead

    double score = post.CreatedAt.ToUnixTimeMilliseconds();
    await foreach (long[] followers in follows.GetActiveFollowerPagesAsync(post.AuthorId, pageSize: 1_000, ct))
    {
        var batch = redis.CreateBatch();                // one round trip per 1,000 followers
        var writes = new List<Task>(followers.Length * 2);
        foreach (var f in followers)
        {
            writes.Add(batch.SortedSetAddAsync($"feed:{f}", post.Id, score));
            writes.Add(batch.SortedSetRemoveRangeByRankAsync($"feed:{f}", 0, -801));  // keep newest 800
        }
        batch.Execute();
        await Task.WhenAll(writes);
    }
}

Why can't you just "push every post to every follower"? Because of one person with 50 million followers. ⭐

📤 Fan-out on write: fast reads, but one celebrity post = 50M writes

📥 Fan-out on read: cheap writes, but slow feeds

🔀 Hybrid: push for normal users, pull for celebrities, merge at read time

🧠 Store only post IDs in Redis, hydrate the posts from a shared cache

👉 Use cursors, not page numbers, or users see duplicates as new posts arrive

Where would you set the "celebrity" threshold? #SystemDesign #Scalability #Redis #Architecture

21.6 Video streaming platform (YouTube / Netflix-style) Added

"Design YouTube or Netflix: creators upload videos, and millions watch them on any device without buffering." The work is a one-off processing pipeline per upload, then a huge read path that the CDN must carry.

1. Requirements

  • F: upload large files (resumable), process into streamable formats, watch on phone/TV/web at the right quality, search, view counts, live streams
  • NFR: 100M daily viewers, 500k uploads/day (avg 6 min); playback starts < 2 s; rebuffering < 1% of watch time; new uploads playable within minutes; 99.95% playback availability, worldwide
  • Out of scope: ads, comments, creator payouts; recommendations in one line

2. Estimates

  • Uploads: 500k ÷ 86,400 ≈ 6/s; 500k × 6 min = 3M minutes/day (≈ 35 hours of video every minute)
  • Ladder size: 5 rungs (0.4 + 0.8 + 1.4 + 2.8 + 5 Mbps) ≈ 10.5 Mbps × 60 s ÷ 8 ≈ 80 MB per video minute
  • Storage: 3M × 80 MB = 240 TB/day ≈ 88 PB/year, plus the originals. Storage tiers are not optional
  • Encoding: 3M min × 5 rungs = 15M rung-minutes/day. If one vCPU does ~1 min per min (very rough), ÷ 1,440 ≈ 10,000 vCPUs busy all day
  • Egress at peak: 5M watching at once × 3 Mbps = 15 Tbps. At a 95% CDN hit ratio the origin still serves 750 Gbps; each 1% of misses = 150 Gbps
  • Read:write: 100M × 40 min watched vs 3M min uploaded ≈ 1,300 : 1

3. API

CallDoes
POST /videos {title, sizeBytes}201 + {videoId, uploadUrl}: a write-only SAS / pre-signed URL for one blob, valid ~1 h
PUT {uploadUrl} blocks, then commit the block listClient uploads 8 MiB chunks straight to storage, in parallel; resume = resend only the missing blocks
BlobCreated event (no client call)Starts the processing pipeline
GET /videos/{id}Metadata, status, thumbnails
GET /videos/{id}/playShort-lived manifest URL (+ DRM licence URL)
POST /views (batched heartbeats)202; counted async

4. Data model

StoreKeyColumns / content
videos (SQL / Cosmos DB)video_idowner, title, status (uploading → processing → ready), duration
renditions(video_id, rung)codec, resolution, bitrate, path
Blob storagepathraw/{id}, vod/{id}/720p/seg_00042.m4s, master.m3u8, manifest.mpd
view_countsvideo_idtotal; flushed from Redis every ~30–60 s
search indexvideo_idtitle, tags, caption text

5. High-level design

6. Deep dives

Resumable direct upload

  • Never stream gigabytes through your API servers. Hand out a short-lived, write-only URL for one blob.
  • Block blobs: upload blocks in parallel, then commit the list. After a drop, ask which blocks arrived and send the rest.
  • Treat the upload as untrusted until probed and scanned.

Transcoding pipeline

  • Split at keyframes, encode each segment × rung in parallel, then stitch. A 10-min video = 150 segments × 5 rungs = 750 small jobs: minutes, not an hour.
  • Same GOP length on every rung, so the player can switch at any segment boundary.
  • Jobs are idempotent per (video, segment, rung), so retries and Spot evictions are safe.

Adaptive bitrate (ABR)

  • HLS (.m3u8, Apple) and MPEG-DASH (.mpd): a manifest plus small segments over plain HTTP.
  • CMAF (fragmented MP4): one set of segments serves both, so you store and cache them once.
  • The player, not the server, picks the rung for each next segment from measured throughput and buffer level.

CDN and cache hit ratio

  • Segments never change: cache them with a long max-age. Live manifests get a TTL of a second or two.
  • Hit ratio is the key metric: at 15 Tbps, each 1% of misses is 150 Gbps on the origin.
  • Origin shield (tiered cache) collapses misses; pre-warm before a big premiere.

Protecting content

  • Tokenised URLs: the play API signs a manifest URL that expires in minutes. Stops hotlinking.
  • Premium content adds DRM: encrypt once (CENC), then a licence per device: Widevine (Chrome, Android), FairPlay (Apple), PlayReady (Windows, many TVs).
  • The licence server checks the entitlement, not the CDN.

Metadata, search, views

  • Metadata in SQL / Cosmos DB; a search index fed from change events. Captions make speech searchable.
  • Views: INCR in Redis, flush to the DB every minute; unique viewers with HyperLogLog (05 probabilistic). Counts lag a little by design.
  • Recommendations: offline candidate generation + online ranking on watch history.

Live streaming (short version)

  • Ingest: the broadcaster pushes RTMP or SRT to the nearest ingest point.
  • Transcode in real time: you can't split the future, so each live channel needs reserved encoder capacity.
  • Low-latency HLS: small partial segments (a few hundred ms), blocking playlist reload, preload hints.
  • Latency: classic HLS with 6 s segments is often 10–30 s behind; LL-HLS gets to a few seconds. Sub-second (auctions, calls) needs WebRTC, which costs more to fan out.
  • Record the stream and publish it as VOD afterwards.

Cost: where the money goes

  • Egress is the big bill. Hit ratio, better codecs for devices that support them, and CDN contracts matter more than compute.
  • Storage tiers: hot for new and popular videos, cool / cold for the long tail, archive for originals (180-day minimum, hours to rehydrate).
  • Per-title encoding: build the ladder per video. Netflix (2015) streamed a simple cartoon at 1080p for 1,540 kbps, where its fixed ladder gave 480p at 1,750 kbps.
  • Encode the top rungs lazily, once a video actually gets views.

7. Bottlenecks & scaling

BottleneckFix
Transcode backlog after a spikeQueue + autoscale Spot workers on queue depth; publish low rungs first
Viral video or premiere hits the originOrigin shield, pre-warm the CDN, long TTL on segments
Egress costHigher hit ratio, multi-CDN deals, per-title encoding, newer codecs where supported
Hot view counterSplit into N Redis keys and sum; flush async in batches
Storage growthLifecycle rules hot → cool → cold; originals to archive; drop unwatched top rungs
  • Upload straight to object storage with a short-lived write-only URL. The API never touches the bytes.
  • Split, encode in parallel to a bitrate ladder, package once as CMAF for HLS and DASH.
  • The player picks the bitrate; the CDN carries the load. Hit ratio and egress decide the bill.
  • View counts are approximate and async: Redis counters + periodic flush, HyperLogLog for uniques.
The .NET stack for this design
ComponentLibrary / APINotes
Upload APIbuilt-in minimal APIs + Azure.Storage.Blobs (GetUserDelegationKeyAsync, BlobSasBuilder), Azure.IdentityUser delegation SAS (Entra-signed, no account key); Create/Write only, short expiry
PipelineAzure.Messaging.EventGrid, Azure.Messaging.ServiceBus; Microsoft.Azure.Functions.Worker.Extensions.DurableTaskBlobCreated starts a Durable orchestration that fans out per segment and rung
Encoding workersFFMpegCore (MIT); Xabe.FFmpeg commercial licenceRun in Container Apps jobs, AKS Spot node pools or Azure Batch Spot VMs
Delivery + playbackAzure Front Door (Blob origin); minimal API that signs manifest URLsFront Door has no built-in signed-URL check: enforce access in the play API and DRM licence server
View countsStackExchange.Redis (StringIncrementAsync, HyperLogLogAddAsync)A BackgroundService flushes totals to the DB every minute
Buy, not buildMux.Csharp.Sdk, AWSSDK.MediaConvert, Google.Cloud.Video.Transcoder.V1Cloudflare Stream has no official .NET SDK: call its REST API with HttpClient
// Durable Functions (isolated worker): fan out every segment x rung, then fan in
[Function(nameof(TranscodeVideo))]
public static async Task<string> TranscodeVideo(
    [OrchestrationTrigger] TaskOrchestrationContext ctx)
{
    var videoId = ctx.GetInput<string>()!;
    var segments = await ctx.CallActivityAsync<string[]>(nameof(Split), videoId);

    string[] ladder = ["1080p", "720p", "480p", "360p", "240p"];
    var encodes = from s in segments
                  from r in ladder
                  select ctx.CallActivityAsync<string>(nameof(Encode), new Job(s, r));
    string[] outputs = await Task.WhenAll(encodes);  // fan in: 10 min = 750 tasks

    return await ctx.CallActivityAsync<string>(nameof(Package), outputs);
}

[Function(nameof(Encode))]
public static async Task<string> Encode([ActivityTrigger] Job job)
{
    var output = Path.ChangeExtension(job.Segment, $".{job.Rung}.mp4");
    await FFMpegArguments.FromFileInput(job.Segment)
        .OutputToFile(output, true, o => o
            .WithVideoCodec(VideoCodec.LibX264)
            .WithVideoBitrate(Ladder.Kbps[job.Rung])
            .WithCustomArgument($"-vf scale=-2:{Ladder.Height[job.Rung]}"))
        .ProcessAsynchronously();
    return output;               // real code: download from / upload to Blob Storage
}

Azure Media Services was retired on 30 June 2024, so Azure has no first-party encode/package/DRM service any more: build with FFmpeg or buy (Mux, Cloudflare Stream, AWS Elemental MediaConvert, Google Transcoder API). FFMpegCore is MIT, but the ffmpeg binary you ship is LGPL, or GPL if built with parts like libx264: check your build. For very large fan-outs, use one sub-orchestration per rung to keep orchestration history small.

Why does a show start a bit blurry and turn sharp after a few seconds? That's the design working. 🎬

📤 Uploads go straight to object storage with a short-lived URL, never through your API

✂️ The video is cut into ~4-second segments and encoded in parallel at 5+ quality levels

📦 One set of CMAF segments serves both HLS and DASH

📶 The player measures your bandwidth and picks the quality of every next segment

🌍 The CDN serves 95%+ of the bytes; every 1% of misses lands on your origin

💸 Egress, not storage, is the big bill

Start low, step up, never stall. #SystemDesign #VideoStreaming #CDN #Architecture #DotNet

21.7 Payment ledger service Added

"Design the ledger that records every money movement for a digital wallet or bank account product." Here correctness beats speed: every cent must balance, and nothing is ever edited or lost.

1. Requirements

  • F: post transfers between accounts, show balances and statements, reverse a posting, reconcile with external settlement files
  • NFR: 10M accounts, 50M transactions/day; strong consistency; no overdraw; exactly-once effect for retries; full audit trail; multi-year retention
  • Out of scope: card authorisation, FX, the payment scheme connections themselves

2. Estimates

  • Transactions: 50M ÷ 86,400 ≈ 580/s, ~5k/s at peak (payday, sale events)
  • Entries: at least 2 per transaction = 100M/day × 200 B ≈ 20 GB/day ≈ 7 TB/year
  • Reads: balance checks ~10× writes. Read "available" from the primary (never a stale cache); serve statements from replicas

3. API

EndpointDoes
POST /transfers + Idempotency-KeyPosts a balanced transaction; same key returns the same result
GET /accounts/{id}/balanceLedger and available balance
GET /accounts/{id}/entries?cursor=Statement lines
POST /transfers/{id}/reversalNew opposite entries (never delete)

4. Data model (SQL)

TableKeyColumns
accountsaccount_idcurrency, available_balance, version
transactionstx_id; unique idempotency_keytype, reference, status, created_at
entries (append-only)entry_idtx_id, account_id, amount (+/−, minor units), posted_at
outboxidevent type, payload, published_at

5. High-level design

6. Deep dives

Double-entry, append-only

  • Every transaction has entries that sum to zero, so money is never created or lost.
  • Never UPDATE or DELETE an entry. Fix mistakes with a reversal (new opposite entries).
  • Store amounts as integers in minor units (cents), never floating point.

Balances: stored or derived?

  • Derived (sum of entries): always correct, slow for busy accounts.
  • Stored balance updated in the same transaction: fast reads and a cheap overdraw check.
  • Common answer: store it, and a nightly job proves it equals the sum of entries.

Concurrency and idempotency

  • Conditional update WHERE available >= amount (or optimistic version check) stops two withdrawals racing.
  • Lock / update accounts in a fixed order (by ID) to avoid deadlocks.
  • Unique index on the idempotency key makes client retries safe (08 idempotency).

Across services

  • Outbox in the same transaction, then events: no "DB saved but event lost".
  • Multi-step flows (debit here, credit at another bank) use a saga with a holding/suspense account and compensations.
  • Tamper evidence: SQL ledger tables or hash-chained entries for auditors.

7. Bottlenecks & scaling

BottleneckFix
Hot account (a merchant or the bank's own settlement account)Split into N sub-accounts and sum them; or batch postings for that account every few seconds
Write throughput of one primaryShard by account ID; a transfer across shards becomes a saga through a suspense account
Statement readsRead replicas or a CQRS read model built from events
Growing entries tablePartition by month; archive old partitions to cheaper storage, keep them queryable
  • Double-entry, append-only, integers in minor units. Say all three.
  • Entries, balance update and outbox row commit in one ACID transaction.
  • Idempotency key + conditional balance update = no double charge and no overdraw.
  • Reconciliation is part of the design, not an afterthought.
The .NET stack for this design
ComponentLibrary / APINotes
APIbuilt-in minimal APIs, AddProblemDetails()Return RFC 9457 errors like "insufficient funds"
Ledger storeEF Core 10 (Microsoft.EntityFrameworkCore.SqlServer): ExecuteUpdateAsync, explicit transactions, row versionsAzure SQL / SQL Server 2022+ ledger tables for tamper evidence
Outbox + eventsWolverineFx (EF Core outbox) + Azure.Messaging.ServiceBusOr a small relay BackgroundService of your own
ReconciliationQuartz (4.x; DI and hosting built in), CsvHelperNightly job: parse settlement files, match, raise breaks
Audit and tracingOpenTelemetry; Microsoft.Extensions.Compliance.RedactionTrace ID on every transaction; no account numbers in logs
await using var tx = await db.Database.BeginTransactionAsync(ct);

db.Transactions.Add(new LedgerTx(txId, idempotencyKey, req.Reference));  // unique index on the key
db.Entries.AddRange(
    new Entry(txId, req.From, -req.AmountCents),      // money out
    new Entry(txId, req.To,   +req.AmountCents));     // money in: the entries sum to zero
db.Outbox.Add(OutboxMessage.From(new TransferPosted(txId)));
await db.SaveChangesAsync(ct);

int debited = await db.Accounts                         // atomic "no overdraw" check
    .Where(a => a.Id == req.From && a.AvailableCents >= req.AmountCents)
    .ExecuteUpdateAsync(s => s.SetProperty(a => a.AvailableCents, a => a.AvailableCents - req.AmountCents), ct);
if (debited == 0)
{
    await tx.RollbackAsync(ct);
    return Results.Problem("Insufficient funds", statusCode: 422);
}
await db.Accounts.Where(a => a.Id == req.To)
    .ExecuteUpdateAsync(s => s.SetProperty(a => a.AvailableCents, a => a.AvailableCents + req.AmountCents), ct);

await tx.CommitAsync(ct);
return Results.Created($"/transfers/{txId}", txId);

A duplicate idempotency key fails SaveChangesAsync with a unique-index violation: catch it and return the original result. Update the two accounts in ID order, not from/to order, to avoid deadlocks between opposite transfers.

A ledger is the one system where "eventually correct" is not good enough. 🏦

📒 Double-entry: every transaction's entries add up to zero

🚫 Append-only: never edit an entry, post a reversal instead

🔢 Integers in cents, never floats

🔑 Idempotency key + "WHERE balance >= amount" = no double charge, no overdraw

📤 Outbox row in the same transaction, so events are never lost

🧮 Reconcile daily against the outside world

Boring by design. That's the point.

#Banking #Payments #SystemDesign #FinTech #DotNet

⚡ Quick-fire Q&A

How do you start any design case study in an interview?

Clarify 2–3 core features and the key numbers (users, read:write ratio, latency, availability), do a quick estimate, then sketch the API and data model before drawing boxes. The estimate tells you whether you need caching, sharding or queues.

URL shortener: how do you avoid two links getting the same code?

Don't rely on random luck. Either encode a unique counter in base62, or hand each server a block of pre-generated keys from a key service. If you hash the URL, check for a collision and retry with a salt.

Rate limiter: what happens if Redis goes down?

Decide per endpoint. Most APIs fail open with a local in-memory limiter as a safety net. Login, OTP and payment initiation fail closed, because abuse costs more than a short outage.

Chat: how do you keep messages in order?

Give each conversation its own sequence number (Redis INCR or one stream partition per conversation). Clients sort by it and fetch any gaps. Global ordering isn't needed.

Feed: what is the celebrity problem?

With fan-out on write, one post by an account with millions of followers means millions of feed writes. The hybrid fix: push posts from normal accounts, and pull celebrity posts at read time and merge them in.

Video: why cut a video into segments at several bitrates?

Segments let you encode in parallel (minutes, not hours) and let the player switch quality at every segment boundary. The bitrate ladder gives every network a rung it can play without stalling, and small immutable segments cache well on the CDN.

Video: what is the biggest cost, and how do you cut it?

Egress (CDN bandwidth), not storage or compute. Keep the cache hit ratio high, use per-title encoding and newer codecs where devices support them, and move the long tail of rarely watched videos to cooler storage tiers.

Ledger: why store a balance if you can sum the entries?

Summing is slow for busy accounts and makes the overdraw check expensive. Store the balance, update it in the same transaction as the entries, and run a nightly check that it still equals the sum.

← 20 · System Design Playbook