System design · 2 of 5

Scalability and compute patterns

Horizontal and vertical scaling, microservices, statelessness, concurrency models, queues and batching.

Updated 2026-09-08
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.

Figure 1. The whole article on one page. Every branch is a section below; fold what you know, open what you do not.

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.

Little's law

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.

In the wild
  • 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
Figure 2. Nodes are interchangeable; the autoscaler watches a signal and changes their count.
VerticalHorizontal
Change neededNone in codeNodes must be stateless or the data must be partitioned
CeilingLargest instance availableNone in principle; the shared components become the limit
FailureSingle point of failureN+1: lose a node, keep serving
Cost curveSteep at the top endRoughly linear, plus the load balancer and orchestration
DeploysRestart the one machineRolling: replace nodes a few at a time with zero downtime
Best forDatabases, stateful services, early stageWeb 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:

In the wild
  • 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.
Watch out

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
Figure 3. One deployable and one database above; services that own their data and talk over the network below. Everything that is easy about the first becomes work in the second.

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

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.

In the wild
  • 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".
Watch out

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
Figure 4. Move the state out and the balancer, the autoscaler and the deploy tool all get simpler.

Where the state goes

StateInstead of local memory or disk
Login sessionA signed token the client carries (JWT), or a session id looked up in Redis
Uploaded filesObject storage (S3, GCS), with the node streaming through or the client uploading directly with a signed URL
Shopping cart, draftsDatabase or Redis keyed by user
Background job progressThe queue and a job table, so another worker can resume
CachesFine locally as an optimisation, never as the source of truth; a shared cache when nodes must agree
Scratch filesLocal 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.

In the wild
  • 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.
Watch out

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
Figure 5. A thread pool holds one thread per in-flight request and most of them are waiting; an event loop is one thread with non-blocking I/O that asks the OS to say when a socket is ready.
ModelHow it worksStrong atWeak atSeen in
Thread per requestEach connection gets an OS thread that blocks on I/OSimple code, CPU-bound workMemory per thread, context switching; a few thousand connections at mostClassic Apache, Tomcat, Spring MVC
Thread poolA fixed number of threads pull work from a queueBounded resource use, predictablePool exhaustion when a dependency is slow: every thread ends up waiting on itMost Java and .NET servers
Event loopOne thread, non-blocking I/O, callbacks or promises when data arrivesTens of thousands of mostly idle connectionsAny CPU-heavy step blocks everyone; needs worker threads for thatNGINX, Node.js, Redis, Netty
Lightweight threadsThousands of cheap user-space threads multiplexed onto a few OS threads by the runtimeBlocking-style code with event-loop scalabilityRuntime must own all blocking calls; pinned or native calls can stall a carrierGo 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()
}
In the wild
  • 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".
Watch out

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]
Figure 6. The API answers in milliseconds regardless of how long the queue is; the queue depth becomes the scaling signal.

What a queue gives you

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.

SQSRabbitMQKafka
ModelManaged queue; message deleted after ackBroker with exchanges and routing; message deleted after ackAppend-only log; messages retained, consumers track their offset
OrderingBest effort; FIFO queues order per message groupPer queuePer partition, by key
ReplayNoNoYes: rewind the offset, add a new consumer group that reads from the start
ParallelismAny number of consumersAny number of consumers per queueOne consumer per partition within a group
ThroughputHigh, pay per requestTens of thousands per second per nodeMillions per second per cluster
Reach for it whenBackground jobs on AWS, minimal opsComplex routing, RPC-style work queuesEvent 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.

In the wild
  • 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.
Watch out

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
Figure 7. Same work, one third of the waiting. At a thousand rows the difference is seconds versus milliseconds.

Where it shows up

// 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.

In the wild
  • 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.

In the wild
  • 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.ms and batch.size are 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.