Everything here follows from one fact: a network call can fail in a
way where you cannot tell whether it happened. Messaging, retries, idempotency and
locks are all different responses to that single uncertainty.
10
Messaging
Kafkaqueuespub-suborderingdelivery semantics
Why it exists
A synchronous call couples two services in availability (if the callee is down,
the caller fails), in capacity (a traffic spike hits both at once), and in time
(the caller waits). A broker breaks all three: the producer writes and moves on,
the broker absorbs the spike, and the consumer works at its own pace. You are
trading immediate feedback for decoupling.
Queue vs log
Queue (SQS, RabbitMQ)
Log (Kafka, Pulsar, Kinesis)
Model
Message is consumed and deleted
Append-only log; consumers track an offset
Consumers
Competing — one message, one worker
Independent groups each read everything
Replay
No — it's gone
Yes — rewind the offset. Huge for bugs and new consumers.
Ordering
Best-effort (FIFO queues cost throughput)
Strict, per partition
Retention
Until consumed
Time or size based (days to forever)
Throughput
High
Very high — sequential disk writes, batching
Best for
Task distribution: "someone process this"
Event streams: "this happened", many interested parties
Delivery semantics
Semantic
Mechanism
Risk
Use for
At-most-once
Ack before processing
Message lost on a crash
Metrics, telemetry, where loss beats duplication
At-least-once
Ack after processing
Duplicates on retry
Almost everything. The default.
Exactly-once
Only within a closed system (e.g. Kafka read-process-write transactions)
Cannot span an external side effect — the moment you call Stripe, it's off the table
Stream processing inside one platform
The exactly-once trap
If an interviewer asks for exactly-once delivery, the strong answer is:
"Exactly-once delivery isn't achievable across a network — the ack itself can
be lost. What's achievable is at-least-once delivery plus idempotent processing,
which gives exactly-once effects. That's what I'd build."
This one sentence separates candidates.
Ordering
Global ordering across a distributed log is a throughput ceiling — it means one
partition, one consumer, no parallelism. What you actually want is ordering
within a key:
partition = hash(ordering_key) % num_partitions
ordering_key = conversation_id # messages in a chat stay ordered
ordering_key = account_id # ledger entries per account stay ordered
ordering_key = order_id # created → paid → shipped, in order# Consequences to state out loud:
# · One key's throughput is capped by one partition.
# · Increasing partition count re-maps keys — ordering breaks across the change.
# · A slow message blocks its partition (head-of-line blocking).
The dual-write problem and the outbox pattern
The single most valuable messaging pattern to know, because the naive version is
broken and everyone writes the naive version first.
# BROKEN — two systems, no shared transaction
db.save(order) # succeeds
kafka.publish(OrderPlaced) # crashes here → order exists, nobody knows# FIXED — transactional outbox
BEGIN
INSERT INTO orders (...)
INSERT INTO outbox (topic, payload, created_at) # same transaction
COMMIT
# A separate relay tails the outbox (poll, or change-data-capture via
# Debezium) and publishes. It may publish twice after a crash — which is
# fine, because consumers are idempotent. Atomicity is preserved because
# both rows commit together or neither does.
Outbox for atomicity, partitions for ordered parallelism, independent offsets for isolation.
Consumer groups and rebalancing
Each partition is owned by exactly one consumer in a group. More
consumers than partitions means idle consumers — partition count is your
parallelism ceiling, so over-provision partitions early (they're cheap; adding
them later breaks key→partition mapping).
A consumer joining, leaving or timing out triggers a rebalance,
which stops consumption briefly. Frequent rebalances usually mean processing takes
longer than the poll interval — fix by processing faster, batching smaller, or
raising max.poll.interval.ms.
Consumer lag (latest offset − committed offset) is the single
most important metric on any queue. Alert on it. Rising lag means you're falling
behind permanently, not temporarily.
Say this
"Kafka rather than a plain queue, because I want replay and multiple independent
consumers — the search indexer and the email service both need order events and
shouldn't compete for them. Partition by order_id so one order's
events stay ordered, at-least-once delivery with idempotent consumers keyed on
event_id, and the producer uses a transactional outbox so we never
have an order in the database that nobody was told about."
11
Async Processing
workersjobsbackpressureDLQscheduling
Why it exists
Some work must not happen inside a request. Video transcoding takes minutes;
an emailer being down shouldn't fail a signup; a report can be built while the
user does something else. Moving work off the request path shortens p99 and
decouples your availability from every downstream service's.
What belongs async
Async
Slow: transcoding, PDF generation, ML inference
Third-party: email, SMS, push, webhooks
Fan-out: notify 10,000 followers
Batch: nightly reports, reindexing
Retryable: anything that can safely happen a bit later
Must stay synchronous
Anything whose result the user needs on screen now
Authorisation and validation decisions
Reserving scarce inventory (the last seat)
Anything where "we'll tell you later it failed" is unacceptable
Designing a job
Idempotent. It will run twice. Key on a stable job ID.
Small. One job = one unit. "Send 10,000 emails" should be a
fan-out job that enqueues 10,000 jobs — otherwise a failure at email 9,999 replays
all of them.
Reference, don't embed. Put an ID in the payload and let the
worker fetch. Embedding a 5 MB blob bloats the broker and the payload goes stale.
Versioned. Jobs enqueued before a deploy are consumed after
it. Old payloads must still parse.
Checkpointed, if long-running. A 4-hour job that restarts from
zero on a deploy will never finish.
Time-bounded. A visibility timeout / lease longer than the
worst-case runtime, or another worker picks it up while the first is still going.
Backpressure
Producers are usually faster than consumers. Without a response to that, queues
grow without bound until the broker fills, memory dies, or latency becomes
meaningless. Pick one deliberately:
Strategy
Behaviour
Right for
Buffer
Let the queue absorb the spike
Short bursts with known bounds — the default and often enough
Block / throttle producer
Slow the source down
Internal pipelines where the source can wait
Shed load
Reject with 429/503 at the edge
User-facing: a fast rejection beats a 5-minute timeout
Sample or degrade
Drop some, or reduce quality
Metrics, logs, non-critical enrichment
Autoscale consumers
Add workers on queue depth
Elastic workloads — but watch the downstream database, which does not autoscale
Autoscaling on queue depth has a trap
Scaling workers up because the queue is deep can make things worse: 500 new
workers all hit the same database and take it down, which slows processing, which
deepens the queue, which adds workers. Cap concurrency at what the slowest
shared dependency can take, not at what the queue suggests.
The DLQ is not a graveyard, it's an inbox. It needs: an alert when anything lands
in it, the original message plus the error and stack trace, and a documented,
tested replay path for after you fix the bug. A DLQ nobody looks at
is just a slower way to lose data.
Watch for the poison message: one malformed message that fails,
gets redelivered, fails again, and blocks its partition forever while consuming all
your retry capacity. A max-attempts count that routes to the DLQ is what stops it.
Scheduling and priority
Delayed jobs — a delay parameter (SQS), a Redis sorted set
keyed on run-at, or a scheduler topic.
Cron across N instances — needs a leader or a lock, or every
instance runs the job. See topic 15.
Priority — use separate queues per priority with
dedicated worker pools, not a priority field. A single priority queue starves the
low tier completely; separate pools guarantee low-priority work still moves.
Fairness — one tenant enqueueing a million jobs must not
delay everyone. Per-tenant queues, or weighted round-robin across tenants.
Say this
"The upload API returns 202 with a job ID as soon as the file lands in object
storage; transcoding happens on a worker fleet reading from a queue. Jobs are
keyed on upload_id so a redelivery is a no-op, retries use
exponential backoff with jitter, and after five attempts the job goes to a DLQ
that pages us. Workers autoscale on queue depth but with a hard concurrency cap,
because 500 workers hammering the metadata database is a worse outage than a
slow queue."
12
Rate Limiting
token bucketleaky bucketsliding window429
Why it exists
Capacity is finite and demand isn't. Rate limiting protects you from abuse, from
a buggy client in a retry loop, from one tenant starving the others, and from
yourself. Without it, the failure mode is total: one caller takes down the service
for everyone.
The algorithms
Algorithm
How it works
Bursts
Memory
Weakness
Fixed window
Counter per clock window; reset each window
Allowed, badly
1 int/key
2× burst at the boundary — 100 requests at 0:59 and 100 at 1:01 is 200 in two seconds
Sliding window log
Store every request timestamp; count those inside the window
Exact
O(limit)/key — expensive
Memory at high limits
Sliding window counter
Weighted blend of current + previous window
Smoothed
2 ints/key
Approximate (assumes even distribution) — but the approximation is fine
Token bucket
Tokens refill at rate r, capacity b; each request spends one
Allows a controlled burst up to b
2 values/key
Needs care with clock and refill maths
Leaky bucket
Queue drains at a fixed rate
Smooths them away entirely
Queue size
Adds latency; drops on overflow
Token bucket permits bursts and is the usual choice for APIs; leaky bucket enforces a flat rate and is right when the thing you're protecting genuinely cannot burst.
Distributed rate limiting
With 50 servers, a per-server limit of limit/50 is wrong the moment
traffic is uneven, and a shared counter adds a network hop to every request. The
standard answer is Redis with an atomic script:
-- Token bucket in one atomic Redis Lua call (no read-modify-write race)
local tokens, last = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
local now, rate, cap, cost = tonumber(ARGV[1]), tonumber(ARGV[2]), tonumber(ARGV[3]), 1
tokens = math.min(cap, (tokens or cap) + (now - (last or now)) * rate)
if tokens < cost then return {0, tokens} endredis.call('HMSET', KEYS[1], 'tokens', tokens - cost, 'ts', now)
redis.call('EXPIRE', KEYS[1], 3600)
return {1, tokens - cost}
To avoid the hop on every request, use local buckets with periodic
reconciliation: each server holds a slice of the budget locally and syncs
with Redis every second. Slightly over-permissive during the sync interval, which is
almost always an acceptable trade for removing a network call from the hot path.
Fail open or fail closed?
If Redis is unreachable, do you allow or deny? Fail open for
normal API protection — a rate limiter outage shouldn't be a service outage.
Fail closed for anything guarding a hard resource limit or an
abuse-critical path (login attempts, SMS sending, expensive AI inference), where
unlimited is worse than unavailable. Say which you chose and why.
What to limit, and where
Layer
Limits
Why there
CDN / edge
Per IP, obvious floods, L3/L4 volumetric
Cheapest place to drop a packet is before it reaches you
API gateway
Per API key, per user, per endpoint class
One place to enforce plan quotas
Service
Per tenant, per expensive operation, concurrency caps
Only the service knows a "search" costs 50× a "get"
Database / dependency
Connection pool caps, per-tenant concurrency
The last line — protects the thing that can't scale quickly
Choose the key deliberately. Per-IP alone punishes corporate NAT and mobile
carriers, and is trivially bypassed with a proxy pool; per-user requires
authentication, so unauthenticated endpoints still need an IP or device
dimension. Most real systems layer several: per-IP and per-user
and per-tenant, with different windows. Weight by cost too — a single
expensive query can be worth 100 tokens.
Always return the metadata clients need to behave:
HTTP/1.1 429 Too Many Requests
Retry-After: 30
RateLimit-Limit: 1000
RateLimit-Remaining: 0
RateLimit-Reset: 1756483200
Say this
"Token bucket in Redis, keyed per API key, executed as a Lua script so the
check-and-decrement is atomic. Token bucket rather than fixed window because I
want to allow a short burst — real clients are bursty and a fixed window lets
through 2× the limit at the boundary anyway. 429 with Retry-After so
well-behaved clients back off instead of hammering. If Redis is down I fail open
on read APIs and fail closed on the SMS endpoint, because there the cost of
unlimited is a real bill."
Failure is not an edge case at scale — with 1,000 machines, something is always
broken. Reliability engineering isn't about preventing failure; it's about making
sure a failure stays small. Every pattern below is a way of containing
blast radius.
Timeouts — non-negotiable
Every network call gets a timeout. A call without one waits forever, holding a
thread and a connection; enough of them and your service is down because someone
else's service is slow. Set the timeout from the dependency's p99, not its average
(say p99 × 1.5), and make sure timeouts decrease as you go deeper: if the
client waits 3 s, the API can wait 2 s and the database 1 s. The other way round
means you keep working on requests nobody is listening for any more.
Retries — and how they cause outages
delay = min(cap, base × 2^attempt) × random(0.5, 1.5) # full jitter# Without jitter, every client that failed at T retries at T+1s together —
# a synchronised thundering herd on a service that is already struggling.# Retry amplification: 3 retries per layer over 4 layers = 3^4 = 81×
# load on the bottom service. THIS is how a slow database becomes an outage.
# Fixes:
# · Retry at ONE layer (usually the outermost), not every layer.
# · Retry budgets: cap retries at ~10% of total requests; when the budget
# is spent, fail fast instead.
# · Never retry a non-idempotent call without an idempotency key.
Circuit breakers
The point is not to protect the caller — it's to stop hammering a struggling dependency so it can recover, and to fail in 1 ms instead of 30 s.
Bulkheads
Named after ship compartments: partition your resources so one flooded section
doesn't sink the vessel. Concretely — separate connection pools and thread pools
per dependency, so that when the recommendations service hangs, it exhausts only
its own 20 connections and checkout still has its own pool. Without bulkheads, one
slow dependency consumes every thread in a shared pool and every endpoint goes
down with it.
Bulkheads also apply at coarser grains: separate fleets per tenant tier, separate
clusters per region, separate queues per priority. Shuffle sharding is the elegant
version — give each tenant a random pair of shards from a pool, and one
abusive tenant degrades only the small fraction of tenants sharing both of its
shards.
Graceful degradation
Decide in advance what to drop. A product page that loses recommendations is a
product page; a product page that returns 500 is lost revenue.
When this fails
Don't
Do
Recommendation service
Fail the page
Show a static "popular items" list
Personalisation
Fail the feed
Serve the global/chronological feed
Search backend
Fail the query
Return cached results with a staleness notice
Live inventory count
Block checkout
Hide the number, accept the order, verify async
Analytics pipeline
Block the write path
Drop events; it's telemetry
Availability arithmetic
99% "two nines" 3d 15h down/year ≈ 7.2h/month
99.9% "three nines" 8h 46m/year ≈ 43m/month
99.99% "four nines" 52m/year ≈ 4.3m/month
99.999% "five nines" 5m 15s/year ≈ 26s/month
# Dependencies in SERIES multiply — this is the number that surprises people
5 services at 99.9% each → 0.999^5 = 99.5% (≈ 3.6 hours/month)
# Redundant components in PARALLEL: failure probabilities multiply
2 replicas at 99% each → 1 − 0.01² = 99.99%
# Which is the whole argument for redundancy AND for having fewer
# hard dependencies on the critical path. Making a dependency optional
# (degrade instead of fail) removes it from the series product entirely.
Say this
"Every outbound call has a timeout derived from the dependency's p99, retries
with exponential backoff and full jitter but only at the edge so we don't get
retry amplification, and a circuit breaker that opens at a 50% error rate so we
stop hammering something that's already down. Each dependency gets its own
connection pool, so a hang in recommendations can't starve checkout. And
recommendations is explicitly optional — if it's down we serve a static list,
which keeps it out of the availability product entirely."
14
Idempotency
duplicatesdedupreplay safetyexactly-once effects
Why it exists
The client sends "charge ₹5,000", the network drops the response, and the client
has no way to know whether the charge happened. It must either retry (risking a
double charge) or give up (risking a lost payment). Idempotency removes the
dilemma: retrying is always safe, so the client can always retry.
This is not an edge case. At-least-once delivery is the default everywhere —
load balancers retry, queues redeliver, mobile clients reconnect — so
duplicates are the normal condition of a distributed system, not
an anomaly.
Three ways to get it
Approach
How
Best for
Natural idempotency
Make the operation a set, not a delta. status = 'shipped' is idempotent; count = count + 1 is not.
State updates. Always prefer this — it needs no extra machinery.
Idempotency key
Client sends a unique key; server stores key → response and replays it on a repeat.
Creates and side effects: payments, orders, sending mail.
Dedup on a natural ID
Unique constraint on a business key (event_id, transfer_id); a duplicate insert is a caught conflict, not an error.
Event consumers. The database enforces it for you.
A correct idempotent handler
def charge(idem_key, amount, card):
# 1. Claim the key. The UNIQUE constraint IS the lock — this is why
# it works under concurrency, not just under sequential retries.
try:
db.insert(idempotency, key=idem_key, state='IN_PROGRESS',
request_hash=sha256(amount, card))
except UniqueViolation:
row = db.get(idempotency, idem_key)
# Same key, different body = a client bug. Never silently accept it.if row.request_hash != sha256(amount, card):
raise Conflict("idempotency key reused with different payload")
if row.state == 'IN_PROGRESS':
raise Conflict("in flight, retry shortly") # 409return row.response # replay, do nothing# 2. Do the work, passing the key downstream so the PSP dedups too.
result = psp.charge(amount, card, idempotency_key=idem_key)
# 3. Store the response WITH the key, atomically with any state change.with db.transaction():
db.insert(payments, id=result.id, amount=amount)
db.update(idempotency, key=idem_key, state='DONE', response=result)
return result
Details that separate a real answer from a hand-wave
Store the response, not a flag. A retry must return the
original body — otherwise the client can't distinguish "already done" from a
new outcome.
Hash the request. Same key with a different payload means
the client is broken; return 409 rather than silently returning the old result
for a different charge.
Scope the key. Per (endpoint, tenant). A global key space
lets one caller's UUID collide with another's semantics.
Expire keys (24h–7d is typical) and say so — infinite
retention is unbounded storage growth.
Pass the key downstream. Your idempotency is worthless if
your retry to the payment processor creates a second charge there.
Handle the crash between work and record. Either write the
record in the same transaction as the effect, or make the effect itself
idempotent so a replay is harmless.
Exactly-once, honestly
exactly-once DELIVERY = impossible across a network
(the ack can always be lost)
exactly-once EFFECTS = at-least-once delivery
+ idempotent processing
= achievable, and what everyone actually means
# Kafka's "exactly-once semantics" is real but scoped: it covers
# read → process → write when both ends are Kafka, via transactional
# offset commits. The moment you call an external API inside that
# loop, you are back to at-least-once + dedup.
Say this
"Every mutating endpoint takes an Idempotency-Key. I insert the key
first with a unique constraint — that insert is the lock, so two
simultaneous retries can't both proceed — do the work, then store the response
against the key so a later retry replays the exact same response. I also hash the
request body and reject a reused key with different content, because that's a
client bug I'd rather surface than paper over. And I pass the same key to the
payment processor, so the dedup holds end to end."
15
Distributed Locks
leasesfencing tokensleader electionRaft
Why it exists
Sometimes exactly one process may act: one instance runs the nightly job, one
node is the leader, one worker owns a partition. A lock in a single process is a
solved problem; across machines with no shared memory and an unreliable network,
it is genuinely hard — and the naive version is subtly, expensively wrong.
The basic lease
SET lock:job123 <random-uuid> NX PX 30000
# NX = only if absent (atomic acquire)
# PX = auto-expire in 30s, so a crashed holder does not deadlock forever
# the UUID is the ownership proof — release must be conditional:if redis.call("get", KEYS[1]) == ARGV[1] thenreturn redis.call("del", KEYS[1]) # atomic check-and-deleteelse return 0 end# A plain DEL would let you delete a lock that has already expired and
# been acquired by someone else.
The problem no TTL can solve
Process A acquires a 30-second lease. A GC pause (or a VM migration, or a
disk stall) freezes it for 40 seconds. The lease expires; process B legitimately
acquires the lock. Process A wakes up — still believing it holds the lock,
because nothing told it otherwise — and writes. Two writers, corrupted state, and
no amount of TTL tuning fixes it: any timeout you pick can be exceeded by a pause
you didn't predict.
Fencing tokens — the actual fix
Correctness comes from the resource rejecting stale writers, not from the lock service being clever.
The critical implication: fencing requires cooperation from the resource
you're protecting. If the database or object store won't check a token,
you cannot make the lock safe — you can only make it usually work. That is
the honest framing, and stating it is a senior-level signal.
Where to keep the lock
Backend
Guarantees
Tradeoff
Single Redis
Fast, simple lease
Not fault-tolerant for correctness: a failover can lose the lock (async replication) and grant it twice
Redlock (N Redis nodes)
Majority acquisition
Publicly disputed. Relies on bounded clock drift and bounded pauses. Fine for efficiency, contested for correctness.
Another cluster to run; higher latency. The right answer when correctness matters.
Database row lock
Real transactional guarantees
Simple and correct; limited by database throughput and connection holding
Efficiency locks vs correctness locks
Ask which one you need — the answer changes everything.
Efficiency: the lock exists to avoid duplicate work (don't send
the same email twice, don't recompute the same cache entry). A rare double-run is
a wasted resource, not a bug. Redis is fine.
Correctness: a double-run corrupts data or double-spends money.
Use consensus plus fencing tokens — or, better, redesign so you don't need a
distributed lock at all.
Leader election
Often what you actually want. One node holds a lease and renews it; if it dies,
the lease expires and another takes over. Raft/ZAB implementations (etcd, Zookeeper,
Consul) give you this with a session, automatic renewal, and a revision number you
can use directly as a fencing token. Kubernetes does exactly this for controller
leases. Two things to handle: the leader must stop acting the moment it
loses the lease (not when it notices), and every follower must be ready to
take over cold.
Designs that avoid the lock entirely
Partition ownership. Assign key ranges to workers (Kafka
consumer groups do this). Only one worker owns a key, so no lock is needed for it.
Optimistic concurrency.UPDATE … WHERE version = 7;
zero rows updated means someone beat you — reload and retry. Excellent under low
contention and requires no lock service at all.
Database constraints. A unique index does the mutual exclusion
for you, transactionally.
Single-writer per entity. Route all commands for an entity to
one process (actor model, per-key queue). Serialisation by routing instead of by
locking.
Idempotent workers. If running twice is harmless, you never
needed exclusion (topic 14).
Say this
"First I'd ask whether this is an efficiency lock or a correctness lock. For
'only one instance runs the nightly report', a Redis lease with a TTL is
fine — a rare double-run wastes CPU. For 'only one process may write this
balance', a lease is not enough: a GC pause can outlive the TTL and give you two
writers. There I'd use etcd for leader election and pass its revision number as a
fencing token that the storage layer checks. Better still, I'd shard by account
so each account has exactly one owning writer, and skip the lock entirely."