Scalability and compute patterns
Horizontal and vertical scaling, microservices, statelessness, concurrency models, queues and batching.
On this page
A service that works for a thousand users rarely works unchanged for a million. This article is the sequence of things you reach for as load grows: a bigger machine, then more machines, then a structure that lets more machines help, then the concurrency, queues and batching that keep each machine productive.
The map
Read this first when short on time. Every branch is a section below.
Where the limits come from
A single server runs out of one of four things: CPU, memory, network bandwidth, or disk I/O. Which one goes first decides the fix, and one small formula tells you how much concurrency you are really dealing with.
In any stable system, things in progress = arrival rate × time each spends inside. A service handling 2,000 requests per second with a 150 ms average latency has 300 requests in flight at any moment. That number is what you need threads, connections and memory for. It also works backwards: if the pool only holds 100, latency will climb until the equation balances, which is what "the service got slow under load" usually is.
Two words come up constantly. Throughput is work per second. Latency is time per request. Most scaling choices trade one for the other, and batching in particular buys throughput with latency.
Vertical scaling: a bigger machine
Vertical scaling replaces a server with one that has more CPU, memory or faster disks. Nothing in the software changes, which is why it is always the first thing to try and often the right thing to keep doing for a database.
- What it fixes. CPU-bound and memory-bound workloads, and any component that is hard to split: a relational database, a stateful service, a build server.
- Where it stops. There is a largest instance, and the price climbs faster than the capacity. Resizing usually means a restart, and the one big box is still one box: when it fails, everything on it fails.
- The quiet strength. One large machine has no network between its parts. A well-tuned Postgres on 64 cores and a terabyte of RAM handles a load that many teams would reach for sharding to solve, with far less complexity.
- AWS sells single instances with 24 TB of memory (the u-24tb1 family) precisely so SAP HANA and similar databases can keep scaling up.
- Stack Overflow ran one of the busiest sites on the web for years on a handful of very large SQL Server and web machines rather than a large fleet.
- Amazon Aurora and Cloud SQL let you change the instance class with a short failover, which is the managed version of "buy a bigger box".
Horizontal scaling: more machines
Horizontal scaling adds servers to a pool behind a load balancer so that capacity grows with the number of machines. It has no ceiling and survives failures, but only if the servers do not need to share anything.
flowchart TD C[Clients] --> LB[Load balancer] LB --> N1[node 1] LB --> N2[node 2] LB --> N3[node 3] LB -. added at 70% CPU .-> N4[node 4] N1 & N2 & N3 & N4 --> S[(shared state: database, cache, queue)] M[Metrics: CPU, requests, queue depth] --> AS[Autoscaler] AS -- add or remove nodes --> LB
| Vertical | Horizontal | |
|---|---|---|
| Change needed | None in code | Nodes must be stateless or the data must be partitioned |
| Ceiling | Largest instance available | None in principle; the shared components become the limit |
| Failure | Single point of failure | N+1: lose a node, keep serving |
| Cost curve | Steep at the top end | Roughly linear, plus the load balancer and orchestration |
| Deploys | Restart the one machine | Rolling: replace nodes a few at a time with zero downtime |
| Best for | Databases, stateful services, early stage | Web and API tiers, workers, anything stateless |
Shared-nothing
The design rule that makes horizontal scaling work: each node owns its CPU, memory and disk and coordinates with the others only through the network. No shared memory, no shared file system, no "the leader keeps the list". Anything nodes must agree on lives in a separate system built for it: a database, a cache, a queue. That system then becomes the next thing to scale, which is what the storage article is about.
Autoscaling
An autoscaler measures a signal and changes the node count to keep it near a target. The details decide whether it helps or hurts:
- Pick a leading signal. CPU is the default, but it lags: by the time CPU is high, latency already is. Requests per node or queue depth react earlier.
- Respect the lag. A new node takes a minute or more to boot, join and warm up. Scale out early and in slowly, with cooldowns so the fleet does not oscillate.
- Keep a floor. A minimum count that survives one zone failing, and headroom for the burst that arrives before the scaler reacts.
- Scale the whole path. Doubling web nodes doubles database connections. If the database cannot take them, scaling out makes things worse.
- EC2 Auto Scaling groups with target tracking (keep average CPU at 60 percent) are the standard AWS setup; Kubernetes HPA does the same for pods and can scale on custom metrics such as queue length.
- KEDA scales Kubernetes workers on external signals, for example the number of messages waiting in SQS or Kafka lag.
- Cassandra and DynamoDB are shared-nothing at the storage layer: adding a node adds capacity with no leader to bottleneck on.
A fleet that scales out well but shares one database has not scaled; it has moved the bottleneck. Connection pools per node multiply, and the database sees a connection storm on every scale-out. Put a pooler in front of it (PgBouncer, RDS Proxy) and set per-node pool sizes with the total in mind.
Microservices: splitting the system into services
Microservices split an application into separately deployed services, each owning one area of the business and its own data. The point is organisational as much as technical: teams ship independently, and each piece scales on its own curve.
flowchart TB
subgraph Mono["Monolith"]
direction LR
M1[users] --- M2[orders] --- M3[payments] --- M4[search]
M1 & M2 & M3 & M4 --> MD[(one database)]
end
subgraph Micro["Microservices"]
direction LR
U[User service] --> UD[(users db)]
O[Order service] --> OD[(orders db)]
P[Payment service] --> PD[(payments db)]
O -- REST or gRPC --> U
O -- event: OrderPlaced --> Q{{event bus}}
Q --> P
end
Mono ~~~ Micro
Drawing the boundaries
A good service boundary is a bounded context: a part of the domain with its own vocabulary and its own data, where most changes stay inside. "Orders" is a context; "the function that formats dates" is not. Each service owns its database; other services get the data through its API or through events it publishes, never by reading its tables. That rule is what keeps services independently deployable, and breaking it is how you end up with a distributed monolith: many deployables that must still all change together.
How services talk
- Synchronous (REST, gRPC): simple to reason about, but the caller waits, and a slow or failed callee becomes the caller's problem. Timeouts, retries and circuit breakers become mandatory (the reliability article covers them).
- Asynchronous (events on a broker): the producer publishes "OrderPlaced" and moves on; consumers react in their own time. Loosely coupled and resilient, at the cost of eventual consistency and harder debugging.
- Cross-service transactions do not exist. A saga replaces them: a sequence of local transactions with compensating actions if a later step fails (reserve stock, charge card, and if the charge fails, release the stock).
What it costs
Every function call that becomes a network call gains latency, partial failure and serialisation. You now need service discovery, per-service deploy pipelines, distributed tracing to find out where time went, and contracts that let services evolve without breaking each other. Below a certain team size that cost outweighs the benefit, which is why a modular monolith, one deployable with strict internal module boundaries, is the better starting point for most products.
- Amazon's 2002 mandate that every team expose its data only through service interfaces is the origin story; it is what made AWS possible.
- Netflix runs on the order of a thousand services and built much of the open-source tooling (Eureka, Zuul, Hystrix) that others adopted.
- Uber grew to thousands of services and then published its "domain-oriented" consolidation, grouping them into larger domains because the sprawl had become the problem.
- Shopify is a deliberately modular monolith: one Rails codebase with enforced component boundaries, serving a very large share of the world's e-commerce.
- Amazon Prime Video published in 2023 how merging one pipeline's microservices back into a single process cut its infrastructure cost by about 90 percent. The lesson was not "microservices are wrong" but "boundaries in the wrong place are expensive".
Sharing a database between services, or a "common" library that every service must upgrade together, quietly rebuilds the monolith with network calls in between. If two services always deploy together, they are one service.
- Bounded context
- A boundary inside which one model and vocabulary apply. The natural unit for a service.
- Saga
- A multi-step business operation across services implemented as local transactions plus compensating actions instead of one distributed transaction.
- Conway's law
- Systems end up shaped like the organisation that builds them. Service boundaries that ignore team boundaries fight this and usually lose.
Statelessness: any node can serve any request
A stateless server keeps nothing between requests that another server would need. Everything a request depends on arrives with it or lives in shared storage, so any node can handle any request and nodes can be added, removed or replaced at will.
flowchart TB
subgraph Stateful["Stateful: session in memory, sticky routing"]
direction LR
C1[User] -- sticky cookie --> LB1[Balancer] --> A1["node 1<br/>session for this user"]
LB1 -.-> A2["node 2<br/>knows nothing"]
end
subgraph Stateless["Stateless: session outside, any node"]
direction LR
C2[User] -- JWT or session id --> LB2[Balancer]
LB2 --> B1[node 1]
LB2 --> B2[node 2]
B1 & B2 --> R[(Redis: sessions)]
B1 & B2 --> S3[(object storage: uploads)]
end
Stateful ~~~ Stateless
Where the state goes
| State | Instead of local memory or disk |
|---|---|
| Login session | A signed token the client carries (JWT), or a session id looked up in Redis |
| Uploaded files | Object storage (S3, GCS), with the node streaming through or the client uploading directly with a signed URL |
| Shopping cart, drafts | Database or Redis keyed by user |
| Background job progress | The queue and a job table, so another worker can resume |
| Caches | Fine locally as an optimisation, never as the source of truth; a shared cache when nodes must agree |
| Scratch files | Local disk is allowed, as long as losing the node loses nothing that matters |
The payoff shows up everywhere else in this article. Autoscaling works because a new node is instantly useful. Rolling deploys work because killing a node loses nothing. A crash is an inconvenience, not data loss. And the load balancer can use any algorithm it likes, because sticky sessions are no longer needed.
- AWS Lambda is statelessness taken to its end: each invocation may run on a fresh container, so anything kept in memory is a cache at best.
- Kubernetes treats Deployments as stateless by default (pods are disposable) and makes you opt into StatefulSets for the exceptions such as databases.
- The twelve-factor app methodology, written at Heroku, made "processes are stateless and share-nothing" a rule that most platforms now assume.
- ALB sticky sessions exist as the escape hatch for legacy apps that keep sessions in memory; new systems rarely need them.
A "stateless" service with an in-memory cache is stateless right up to the moment two nodes hold different values and users see data flip between requests. Either make the cache a pure performance layer with short TTLs, or share it.
Concurrency: keeping one machine busy
Concurrency is how a server makes progress on many requests at once. The model you pick decides how many connections a machine can hold, what happens when a downstream call is slow, and how the code looks.
flowchart TB
subgraph Pool["Thread pool"]
direction LR
Q1[request queue] --> T1[thread 1: waiting on DB]
Q1 --> T2[thread 2: waiting on HTTP]
Q1 --> T3[thread 3: computing]
Q1 --> TN[... 200 threads, mostly waiting]
end
subgraph Loop["Event loop"]
direction LR
E[loop] --> H1[start DB query, register callback]
E --> H2[start HTTP call, register callback]
E --> H3[run ready callbacks]
OS[OS: epoll, kqueue] -- socket ready --> E
end
Pool ~~~ Loop
| Model | How it works | Strong at | Weak at | Seen in |
|---|---|---|---|---|
| Thread per request | Each connection gets an OS thread that blocks on I/O | Simple code, CPU-bound work | Memory per thread, context switching; a few thousand connections at most | Classic Apache, Tomcat, Spring MVC |
| Thread pool | A fixed number of threads pull work from a queue | Bounded resource use, predictable | Pool exhaustion when a dependency is slow: every thread ends up waiting on it | Most Java and .NET servers |
| Event loop | One thread, non-blocking I/O, callbacks or promises when data arrives | Tens of thousands of mostly idle connections | Any CPU-heavy step blocks everyone; needs worker threads for that | NGINX, Node.js, Redis, Netty |
| Lightweight threads | Thousands of cheap user-space threads multiplexed onto a few OS threads by the runtime | Blocking-style code with event-loop scalability | Runtime must own all blocking calls; pinned or native calls can stall a carrier | Go goroutines, Kotlin coroutines, Java virtual threads, Erlang processes |
I/O-bound or CPU-bound
The first question about any workload. If requests spend their time waiting on databases, other services or disks, the machine can hold far more of them than it has cores; the limit is memory per connection, and event loops and lightweight threads shine. If requests spend their time computing, the limit is cores, no concurrency model creates more of them, and a pool of about one thread per core is right. Most web services are I/O-bound, which is why a thread pool of 200 usually holds 200 requests that are all waiting.
// Fetch many URLs concurrently without holding a thread per request.
// Each call suspends while waiting; a small pool of threads serves them all.
suspend fun fetchAll(urls: List<String>): List<Response> = coroutineScope {
val inFlight = Semaphore(20) // bound concurrency: 20 at a time
urls.map { url ->
async(Dispatchers.IO) {
inFlight.withPermit { client.get(url) } // suspends, does not block
}
}.awaitAll()
}
- NGINX displaced Apache by handling ten thousand idle connections on one process with an event loop, the original "C10K" answer.
- Netty is the event-loop engine under Kafka, Cassandra, Elasticsearch and gRPC-Java.
- Go's standard HTTP server spawns a goroutine per connection and scales to hundreds of thousands of them; Java 21 virtual threads brought the same model to the JVM.
- Android's main thread is an event loop (the Looper, see how an app starts) and the rule "never block it" is the same rule as "never block the Node event loop".
Pool exhaustion cascades. One slow dependency ties up every thread in the pool, and the service stops answering even requests that never needed that dependency. Give each dependency its own bounded pool or semaphore (a bulkhead) and a timeout, so a slow payment provider cannot take the product catalogue down with it.
Queues: decoupling producers from workers
A queue sits between the code that creates work and the code that does it. The producer appends a message and returns immediately; workers pull messages at their own pace. That one indirection absorbs bursts, isolates failures and lets each side scale on its own.
flowchart TD P1[API: place order] --> Q[["queue<br/>depth = 12,000 during the sale"]] P2[API: place order] --> Q Q --> W1[worker] Q --> W2[worker] Q --> W3[worker] W1 & W2 & W3 --> D[(database)] W2 -- fails 3 times --> DLQ[["dead-letter queue"]] Q -. depth drives .-> AS[autoscaler adds workers]
What a queue gives you
- Buffering. A sale sends 12,000 orders in a minute; the workers process 2,000 a minute; nobody gets an error, the backlog drains in six minutes.
- Decoupling. The producer does not know or care whether workers are running, slow, or being redeployed.
- Retry and isolation. A message that fails is retried; one that keeps failing goes to a dead-letter queue to be inspected without blocking the rest.
- Fan-out. With pub/sub, one event reaches many independent consumers (email, analytics, fraud check) without the producer listing them.
Delivery guarantees
Brokers hand a message to a worker and wait for an acknowledgement. If the worker crashes before acking, the message is delivered again. That is at-least-once delivery, the default everywhere, and it means every consumer must be safe to run twice (the reliability article covers idempotency). At-most-once acks before processing and accepts loss. Exactly-once in the strict sense does not exist across arbitrary systems; what Kafka offers is exactly-once within Kafka through transactions, and what everyone else means is at-least-once plus idempotent consumers.
| SQS | RabbitMQ | Kafka | |
|---|---|---|---|
| Model | Managed queue; message deleted after ack | Broker with exchanges and routing; message deleted after ack | Append-only log; messages retained, consumers track their offset |
| Ordering | Best effort; FIFO queues order per message group | Per queue | Per partition, by key |
| Replay | No | No | Yes: rewind the offset, add a new consumer group that reads from the start |
| Parallelism | Any number of consumers | Any number of consumers per queue | One consumer per partition within a group |
| Throughput | High, pay per request | Tens of thousands per second per node | Millions per second per cluster |
| Reach for it when | Background jobs on AWS, minimal ops | Complex routing, RPC-style work queues | Event streams, multiple consumers of the same data, replay, log-based integration |
Backpressure
A queue hides overload: the API stays fast while the backlog grows, until someone notices that orders take an hour to confirm. Backpressure is how the system says "slow down" instead. Bound the queue and reject or shed when it is full, alert on consumer lag (how far behind the workers are), scale workers on depth, and give each message a deadline after which processing it is pointless. A queue with no limit is not a buffer, it is a delayed outage.
- Kafka was built at LinkedIn as the log every system writes to and reads from; Uber and Netflix push trillions of messages a day through it.
- SQS plus Lambda is the AWS default for background jobs: the queue triggers the function and scales it with the backlog.
- Google Pub/Sub is the fan-out backbone on GCP; RabbitMQ and Redis Streams serve smaller work-queue needs; Sidekiq and Celery put a job API on top of Redis.
Ordering is only guaranteed where the broker says it is: per partition in Kafka, per message group in SQS FIFO. Two messages for the same order must share a key, or a "cancelled" can be processed before its "placed". And a poison message retried forever blocks the partition behind it; set a retry limit and a dead-letter queue from day one.
- Consumer group
- A set of workers that share one subscription; each message goes to one of them. Two groups each get every message.
- Visibility timeout
- SQS's name for the window in which a delivered message is hidden from other workers while one processes it.
- Consumer lag
- Messages produced but not yet consumed. The single best health metric for a queue.
Batching: fewer, bigger operations
Batching groups many small operations into one larger one so that the fixed cost per operation, mostly the network round trip, is paid once. It is the most reliable way to raise throughput, and its price is always the same: the first item waits for the batch to fill.
sequenceDiagram participant A as App participant D as Database Note over A,D: one at a time: 3 round trips, ~3 ms each A->>D: INSERT row 1 D-->>A: ok A->>D: INSERT row 2 D-->>A: ok A->>D: INSERT row 3 D-->>A: ok Note over A,D: batched: 1 round trip, one transaction A->>D: INSERT rows 1, 2, 3 D-->>A: ok
Where it shows up
- Databases. Multi-row inserts and bulk loads; DynamoDB's
BatchWriteItem(25 items) andBatchGetItem(100 items). - Brokers. The Kafka producer holds records for
linger.msand sends them as one request; batches also compress far better than single records. - APIs. A bulk endpoint instead of one call per item; GraphQL's DataLoader collects every "load user by id" made during one request and issues a single query, which is the standard fix for the N+1 problem.
- Telemetry. Metrics and logs are buffered and flushed every few seconds; sending each one would cost more than the work being measured.
- Machine learning serving. GPUs are efficient on batches of inputs, so inference servers wait a few milliseconds to group requests.
// Write 50,000 events in batches of 500: 100 round trips instead of 50,000.
events.chunked(500).forEach { batch ->
jdbc.batchUpdate(
"INSERT INTO events (id, user_id, kind, at) VALUES (?, ?, ?, ?)",
batch.map { arrayOf(it.id, it.userId, it.kind, it.at) }
)
}
# Kafka producer: trade up to 10 ms of latency for much higher throughput
linger.ms=10
batch.size=65536
compression.type=lz4
The trade-off
Every batch is bounded by two limits, a size and a time, and sends when either is hit. Small batches keep latency low; large batches keep throughput high. A partial failure inside a batch also needs an answer: retry the whole batch (fine if writes are idempotent), or retry only the failed items (if the API reports them). And a batch that is too large hits its own limits: request size caps, lock time in the database, memory in the worker.
- Kafka's throughput comes largely from batching and compression at the producer and sequential writes at the broker.
- Facebook built DataLoader for GraphQL because a single page could otherwise issue thousands of identical small queries.
- TCP itself batches: Nagle's algorithm holds small writes to send fuller packets, which is why latency-sensitive protocols turn it off with
TCP_NODELAY. - Spark Structured Streaming processes events in micro-batches for exactly this throughput reason.
Serverless: scaling to zero
Serverless functions are the end point of statelessness plus autoscaling: you upload code, the platform runs one instance per concurrent request and none when idle, and you pay per invocation. The scaling is instant and automatic; the costs are cold starts and limits.
- Fits. Spiky or low traffic, event-driven glue (a file lands in a bucket, a message arrives, a webhook fires), APIs whose load is unpredictable, anything that would otherwise be an idle server.
- Cold starts. The first request to a new instance pays for container start and runtime initialisation: tens of milliseconds for a small Node or Go function, seconds for a large JVM one. Provisioned concurrency, snapshots (Lambda SnapStart) and smaller packages reduce it.
- Limits. Maximum run time (15 minutes on Lambda), memory caps, no persistent local state, and per-request pricing that beats a server at low volume and loses at sustained high volume.
- AWS Lambda with API Gateway, SQS and S3 triggers is the reference architecture; Cloud Run and Fargate apply the same scale-to-zero idea to containers.
- Cloudflare Workers run at the edge with near-zero cold starts by using V8 isolates instead of containers.
- Image thumbnailing on upload is the canonical example: an S3 event invokes a function, which writes the thumbnail back, with no server running in between.
Recap
- Little's law: in-flight = rate × latency. It sizes pools, explains slowdowns, and shows what "more capacity" has to mean.
- Vertical scaling is the cheapest first step and stays right for databases; it ends at the biggest box and a single point of failure.
- Horizontal scaling needs shared-nothing nodes behind a balancer; autoscale on a leading signal, respect boot lag, keep a floor, and remember the database sees every new node.
- Microservices buy independent deploys and scaling at the price of network calls, partial failure and tooling. Boundaries follow bounded contexts and teams; each service owns its data; a modular monolith is the better start for most.
- Stateless nodes push sessions, files and progress into Redis, object storage, databases and queues. That is what makes autoscaling, rolling deploys and crash recovery trivial.
- Concurrency model follows the workload: I/O-bound favours event loops and lightweight threads; CPU-bound is limited by cores. Bulkhead each dependency so one slow call cannot exhaust the pool.
- Queues buffer bursts, decouple producers from workers, retry failures into a DLQ and fan events out. Delivery is at-least-once, so consumers must be idempotent; ordering holds only per partition or group.
- Unbounded queues hide overload; use bounded queues, lag alerts, depth-based scaling and deadlines as backpressure.
- Batching trades a little latency for a lot of throughput: bounded by size and time, everywhere from SQL to Kafka to DataLoader to GPUs.
- Serverless is stateless autoscaling to zero with per-request pricing; ideal for spiky and event-driven work, limited by cold starts and run-time caps.
Questions
Try answering each one out loud before opening it. Lead with the one-line answer, then the reasoning, then one detail from a real system.
When would you scale vertically rather than horizontally?
Vertically when the component is hard to split, especially a relational database, or when the system is early enough that a bigger box is cheaper than the engineering to go stateless; horizontally for stateless tiers and anything that must survive a machine failing.
- Vertical needs no code change but has a ceiling and is a single point of failure.
- Horizontal has no ceiling and gives N+1 resilience but demands shared-nothing nodes and a load balancer.
- Real systems do both: scale the web tier out, scale the database up until sharding is unavoidable.
AWS offers instances with 24 TB of memory because for some databases scaling up remains the right answer far longer than people expect.
What makes a service horizontally scalable?
Nodes that share nothing and hold no state another node needs, fronted by a load balancer, with the shared state pushed into systems designed to be shared.
- Sessions in Redis or in signed tokens, files in object storage, job progress in the queue and database.
- No local disk as a source of truth, no in-memory data that must be consistent across nodes.
- Health checks and draining so nodes can be added and removed without errors.
The test is simple: if any node can be killed at any moment with no user-visible effect beyond a retried request, it scales horizontally.
How does autoscaling decide, and what goes wrong with it?
An autoscaler keeps a chosen metric near a target by adding or removing nodes; it goes wrong when the metric lags, the boot time is ignored, or the shared dependencies cannot absorb the new nodes.
- CPU is a lagging signal; requests per node or queue depth react earlier.
- New nodes take a minute or more to become useful, so scale out early and in slowly, with cooldowns.
- Keep a minimum that survives a zone failure and headroom for bursts.
- Every new node opens database connections; without a pooler, scaling out can overload the database.
KEDA scaling Kubernetes workers on SQS queue length is the cleanest example of scaling on a leading signal.
Monolith or microservices: how do you decide?
Start with a modular monolith and split out services only when a boundary is stable, owned by a separate team, and needs to deploy or scale independently.
- Microservices pay off with many teams, differing scaling curves, and the need for independent releases.
- They cost network latency, partial failure handling, service discovery, tracing, and per-service pipelines.
- A modular monolith gives you the boundaries without the network, and makes later extraction easy.
Shopify runs a modular monolith at enormous scale, and Prime Video cut a pipeline's cost by around 90 percent by merging services that had been split along the wrong line.
What is a distributed monolith and how do you avoid it?
Many deployables that still have to change and deploy together, usually because they share a database or a library that couples their releases; avoid it by giving each service its own data and versioned contracts.
- Other services access data only through APIs or published events, never by reading tables.
- Contracts are backwards compatible so a service can deploy without its callers.
- If two services always deploy together, merge them.
Uber's move to domain-oriented groupings was a response to exactly this: thousands of services with hidden coupling between them.
How do services share data if they cannot share a database?
Through APIs for on-demand reads, through published events for keeping local copies up to date, and through sagas instead of distributed transactions for multi-step writes.
- Synchronous calls are simple but couple availability; add timeouts and circuit breakers.
- Events let a service keep its own read-optimised copy of another's data, accepting eventual consistency.
- Change data capture can turn a service's database writes into events without extra code.
A saga for checkout reserves stock, charges the card, and releases the stock as compensation if the charge fails.
Why should application servers be stateless, and where does the state go?
Because it lets any node serve any request, which is what makes autoscaling, rolling deploys and crash recovery safe; the state goes into shared systems built for it.
- Sessions: signed tokens or Redis. Files: object storage. Progress: queue and database.
- Local caches are fine as optimisation with short TTLs, never as the source of truth.
- Sticky sessions are the legacy alternative and get in the way of everything above.
Lambda enforces this by design: a function may run on a fresh container for every invocation.
Thread pool or event loop: what are the trade-offs?
A thread pool is simple and handles CPU work naturally but holds one thread per in-flight request, so it caps out at a few thousand connections; an event loop holds tens of thousands of idle connections on one thread but must never run anything slow on that thread.
- Most web work is I/O-bound, which favours the event loop or lightweight threads.
- CPU-bound work on an event loop blocks everyone; push it to worker threads.
- Goroutines, coroutines and virtual threads give blocking-style code with event-loop scalability.
NGINX beating Apache on ten thousand idle connections is the classic demonstration; Java 21 virtual threads are the JVM's answer.
How do you size a thread pool?
From Little's law: the pool must hold arrival rate × latency requests; for CPU-bound work that means about one thread per core, for I/O-bound work cores × (1 + wait time ÷ compute time).
- 2,000 requests per second at 150 ms means 300 in flight, so a pool of 200 will queue and latency will rise.
- Bigger is not free: each thread costs memory and each downstream call costs a connection.
- Give slow dependencies their own bounded pool so they cannot exhaust the main one.
Measure rather than guess: the right number changes with latency, and latency changes with load.
What does a queue buy you, and what does it cost?
It buys buffering of bursts, decoupling of producer from consumer, retries with isolation of bad messages, and fan-out to many consumers; it costs eventual consistency, duplicate delivery, and the risk of hiding overload.
- The producer answers fast regardless of backlog; workers scale on queue depth.
- At-least-once delivery means consumers must be idempotent.
- Without bounds and lag alerts, a queue turns overload into a silent delay.
SQS with a dead-letter queue and a Lambda consumer scaled by backlog is the minimal complete version.
Explain at-most-once, at-least-once and exactly-once delivery.
At-most-once acknowledges before processing and may lose messages; at-least-once acknowledges after and may duplicate them; exactly-once is at-least-once plus idempotent processing, or a transactional guarantee within a single system.
- Every mainstream broker defaults to at-least-once.
- Kafka's exactly-once applies to reading from and writing to Kafka within one transaction, not to side effects elsewhere.
- Practical exactly-once means deduplicating on a key or making the operation naturally idempotent.
The honest phrase is "effectively once": duplicates arrive, but they change nothing.
Kafka, RabbitMQ or SQS: how do you choose?
Kafka for event streams that several consumers read, need replay, or run at very high volume; RabbitMQ for work queues with routing logic; SQS when you want a managed queue on AWS with minimal operations.
- Kafka is a retained log with consumer offsets; the others delete on acknowledgement.
- Kafka parallelism is bounded by partitions; queue consumers scale freely.
- Ordering: per partition in Kafka, per queue in RabbitMQ, per group in SQS FIFO.
Uber and Netflix use Kafka as the integration backbone; most AWS shops start with SQS and only add Kafka when they need replay or multiple consumers.
How do you handle backpressure?
Bound the queue, reject or shed when it is full, alert on consumer lag, scale workers on depth, and attach deadlines to work so stale items are dropped rather than processed.
- Rejecting fast is better than accepting and failing an hour later.
- Reactive streams and TCP flow control are the same idea at the protocol level.
- Prioritise: shed the cheapest-to-lose work first.
Consumer lag is the single most useful queue metric because it measures the thing users feel.
Where does batching help, and what is the trade-off?
Anywhere a fixed per-operation cost dominates, mostly network round trips: database writes, broker sends, API calls, telemetry, GPU inference; the trade-off is that items wait for the batch to fill, so batches are bounded by both size and time.
- Kafka's
linger.msandbatch.sizeare the textbook pair of limits. - DataLoader fixes the N+1 problem by batching identical loads within a request.
- Partial failures need a policy: retry the batch idempotently or retry only failed items.
Nagle's algorithm is batching inside TCP itself, and turning it off is the first fix for chatty latency-sensitive protocols.
When is serverless the right choice, and when is it not?
Right for spiky, low or unpredictable load and event-driven glue where paying per request and scaling to zero beats an idle server; wrong for sustained high throughput, long-running work, or latency budgets that cannot absorb cold starts.
- Cold starts range from tens of milliseconds to seconds; provisioned concurrency and smaller packages reduce them.
- Hard limits: run time, memory, no local persistent state.
- At steady high volume, always-on containers are cheaper per request.
Thumbnail generation on S3 upload is the ideal case; a busy core API with steady traffic usually is not.