Design a queue-based autoscaling worker platform
The interviewer asks
“Design a platform where teams run background workers (image processing, LLM-based document classification, notification sending) that scale up automatically when their queue grows and scale to zero when it is empty. Load is very bursty: a festive sale or a bulk upload can push 2 million jobs into a queue in minutes. Keep cost low without missing processing deadlines.”
What a strong answer looks like
A strong answer starts from the right signal: for queue workers, CPU is a poor scaling metric (an LLM worker waits on the network at 5% CPU), so scale on queue backlog (Kafka consumer lag, SQS depth) and, better, on backlog expressed as time: required workers = arrival rate × processing time per job (Little's Law) plus enough extra to drain the backlog by the deadline. On Kubernetes this is KEDA driving the HPA for pods (including scale-to-zero) and Karpenter or Cluster Autoscaler adding nodes, preferring spot with on-demand fallback. Then the hard parts: limits that make extra workers useless (Kafka partition count, downstream rate limits such as LLM tokens per minute, DB connections), rebalancing storms, stabilisation and cooldown to avoid flapping, cold start from zero, and safe scale-down and spot interruption with graceful shutdown and idempotent jobs. Senior candidates scale on the bottleneck, not just the queue, and show cost per 10k jobs before and after.
Concepts to mention
1 Requirements
Functional
- Teams declare a worker: image, queue source (Kafka topic + group, SQS, Redis stream), min/max replicas, target backlog per worker or deadline
- Scale workers out when backlog grows and in (to zero if allowed) when it shrinks
- Add and remove nodes automatically, preferring spot instances with on-demand fallback
- Respect downstream limits: a worker type can declare a max throughput (e.g. LLM 20M tokens/min, DB 200 connections)
- Graceful shutdown: in-flight jobs finish or are returned to the queue on scale-down and spot interruption
- Dashboards: backlog, age of oldest message, replicas, nodes by capacity type, cost per 1,000 jobs
- Priority classes: payment notifications must not wait behind bulk thumbnail jobs
Non-functional
- Scale-out reaction under 60 s from backlog growth to new pods running (node add under 2-3 min)
- Job deadline SLOs per queue, e.g. 99% of OTP/notification jobs within 30 s; bulk jobs within 1 hour
- No lost or duplicated side effects across scale-down and spot interruptions (effectively-once)
- Cost: idle queues cost ~0 compute; target 60-70% of worker compute on spot
- Platform supports ~200 worker types and peaks of ~50,000 jobs/s across all queues
Out of scope (say it out loud)
- Writing the queue system itself (use Kafka/SQS)
- Long-running multi-step workflows (Temporal or a workflow engine)
- GPU model serving autoscaling (related but separate)
2 Estimates
3 API
| Endpoint | What it does |
|---|---|
PUT/v1/workers/{name} | Declare or update a worker type: image, source, concurrency per pod, min/max, target (backlog per replica or deadline), priority, downstream limit. |
GET/v1/workers/{name}/status | Backlog, oldest message age, replicas desired/actual, throughput, ETA to drain, scaling events. |
POST/v1/workers/{name}:pause | :resume | Pause consumption (e.g. downstream outage) without losing backlog; autoscaler holds replicas at 0. |
POST/v1/workers/{name}/limits | Change the downstream throughput cap at runtime (e.g. provider raised our TPM). |
GET/metrics (Prometheus) | Per-worker metrics scraped and used both by dashboards and by external-metrics scalers. |
4 Data model
worker_specsPostgres + rendered to Kubernetes CRDs (KEDA ScaledObject, Deployment)nameteamimagesource {typetopicgroup}concurrencyminmaxtarget_backlog_per_replica or deadline_spriority_classdownstream_limitspot_okPlatform API is the interface; teams never hand-edit HPA objects. GitOps applies the rendered manifests.
queue metricsPrometheusconsumer_lag{topicgroup}oldest_message_agejobs_completed_totaljob_duration_seconds histogramLag via Kafka exporter or KEDA's Kafka scaler; processing rate derived from completions, needed for deadline-based scaling.
job idempotencyPostgres / Redisjob_id (PK)statusresult_refcompleted_atWorkers check-and-set before side effects so a re-delivered job after a kill is a no-op.
token budgetRedisrl:{provider}:{model} token bucketShared limiter across all pods of a worker type so 50 pods do not exceed the provider limit together.
cost recordsClickHouse / OpenCostworkertsnode_typecapacity_typecpu_secondsjobs_doneCost per 1,000 jobs per worker; drives right-sizing.
5 Architecture
Hover a box to highlight its connections.
6 Request flows
A 2M-job burst arrives and drains
- 1A seller uploads 2M product images for LLM-based category tagging; the upload service writes jobs to topic catalog.classify (48 partitions).
- 2KEDA polls lag every 15 s. Lag jumps from 0 to 300k; with target 5,000 lag per replica it asks the HPA for 60 replicas, capped at maxReplicas 48 (the partition count).
- 3The HPA scales the Deployment from 0 to 48 pods (scale-up policy allows doubling every 15 s). 40 pods are Pending: no node capacity.
- 4Karpenter sees pending pods, picks the cheapest instance types that fit from a diverse spot pool, and launches nodes in ~60-90 s; on-demand is used only if spot capacity is unavailable.
- 5Workers join the consumer group (cooperative-sticky assignment, so existing consumers keep their partitions during rebalance) and process jobs, each pod with 20 concurrent in-flight LLM calls.
- 6Every call first takes tokens from the shared Redis token bucket for the LLM provider; when the bucket is empty, workers slow down instead of hammering the provider into 429s.
- 7Lag falls; after it stays below target for the stabilisation window (5 min) and cooldown (2 min after empty), the HPA scales in gradually, and finally to zero.
- 8Karpenter consolidates: drains under-used nodes (respecting PodDisruptionBudgets) and terminates them.
Spot node is reclaimed mid-job
- 1AWS sends a 2-minute interruption notice; Karpenter (via the interruption queue) cordons the node and starts draining it.
- 2Each worker pod receives SIGTERM, stops polling new messages, finishes in-flight jobs within the 60-90 s grace period, commits offsets for completed jobs only, and exits.
- 3Jobs that did not finish are not committed, so Kafka redelivers them to another consumer after rebalance.
- 4The new consumer checks the idempotency table: a job that had already written its result is skipped, so no duplicate side effects.
7 Deep dives
Choosing the scaling signal
CPU-based HPA is the default in Kubernetes and the wrong one for most queue workers. A worker waiting on an LLM or an HTTP API uses maybe 5% CPU even when 2M jobs are waiting, so CPU scaling never triggers; a CPU-heavy image worker may show 90% CPU while the queue is empty. Queue backlog is the honest signal of waiting work. The simplest form is backlog per replica: desired = ceil(lag ÷ target), where target is how many messages one replica can clear in an acceptable time. A better form reasons in time. With arrival rate λ jobs/s and processing time W s per job, Little's Law says you need λ × W jobs in flight just to keep up; to also clear a backlog B within deadline D you need extra (B ÷ D) × W. Divide by concurrency per pod to get pods. For example λ = 200/s, W = 0.7 s, B = 300k, D = 600 s gives 140 + 350 = 490 in flight, or 25 pods at 20 each. Also scale on the age of the oldest message for latency-sensitive queues (OTP notifications), because a small but stuck backlog matters more than a large fresh one. KEDA supports Kafka lag, SQS depth and arbitrary Prometheus queries, so the platform can compute this formula and expose it as one metric.
The real bottleneck is often not the workers
Adding workers only helps if the workers are the bottleneck. Three common ceilings make extra pods useless or harmful. First, Kafka partitions: consumers in one group cannot exceed the partition count, so the 49th pod sits idle; set maxReplicas to the partition count, create topics with enough partitions for peak (repartitioning later reshuffles keys), or use per-message concurrency inside each pod (e.g. a parallel consumer that processes keys concurrently while keeping per-key order). SQS has no such limit. Second, downstream rate limits: an LLM provider limit of 20M tokens per minute caps 800-token jobs at about 25,000 per minute regardless of pods; scaling to 200 pods just produces 429s and retries that waste money. Use a shared token bucket across pods and cap replicas at limit ÷ per-pod throughput. Third, shared databases: 100 pods × 10 connections = 1,000 connections can exhaust Postgres; use PgBouncer and size pools from the database limit backwards. A strong design lets each worker type declare its downstream limit, and the platform clamps maxReplicas accordingly, surfacing 'throughput-limited by provider' in the dashboard.
Stable scaling: flapping, rebalances and cold starts
A naive autoscaler oscillates: lag spikes, it adds 40 pods, lag drops, it removes them, the next burst arrives. Each change in a Kafka consumer group triggers a rebalance; with the old eager protocol every consumer stops, gives up all partitions and waits, so frequent scaling makes throughput worse. Use cooperative-sticky assignment (or Kafka's newer consumer rebalance protocol) so only moved partitions pause, and static membership so restarted pods reclaim their partitions without a rebalance. On the HPA, scale up fast (allow +100% every 15 s) and scale down slowly (stabilisation window 5 minutes, remove at most 10-20% per minute). Scale to zero saves money for idle queues but adds cold start: KEDA's poll interval, pod scheduling, image pull, client warmup, and possibly a new node, often 1-3 minutes in total. For queues with tight deadlines, keep minReplicas at 1-2 and keep a small buffer of warm node capacity (low-priority placeholder pods that get preempted). Pre-pull large images with a DaemonSet or use smaller images. For predictable peaks such as an 8 PM sale start or IPL match, schedule a pre-scale 10 minutes before (KEDA cron scaler) instead of reacting after the queue builds.
8 Trade-offs
KEDA on Kubernetes vs serverless (Lambda + SQS)
Lambda with SQS scales per message automatically, has no nodes to manage and scales to zero for free, but has a 15-minute limit, cold starts, per-invocation cost that gets expensive at sustained volume, and concurrency limits per account. KEDA on Kubernetes needs more setup but suits long jobs, sustained high volume, custom images and spot pricing. Many companies use both: serverless for spiky low-volume queues, KEDA for heavy ones.
Spot vs on-demand
Spot is ~60-80% cheaper but can be reclaimed with 2 minutes' notice. Queue workers are ideal for spot if jobs are short or checkpointed and idempotent. Keep latency-critical queues (OTP, payment notifications) on on-demand or with an on-demand minimum, and use many instance types to reduce interruption risk.
Per-team clusters vs shared platform
A shared cluster with priority classes and quotas packs work tightly and cuts cost, but needs fairness rules so one team's burst does not starve others. Priority classes let payment workers preempt bulk jobs; ResourceQuotas cap each team.
9 Bottlenecks
Node provisioning speed during a huge burst
Launching 100 nodes takes minutes and spot capacity in one instance type can run out. Allow many instance families and sizes, multiple AZs, and keep a warm buffer; predictable events get scheduled pre-scaling.
Metrics pipeline lag
If the scaler reads lag from Prometheus with a 60 s scrape interval plus a 15 s KEDA poll, reaction is slow. Use direct scalers (KEDA reading Kafka/SQS directly) for the primary signal and shorter intervals for latency-critical queues.
Poison messages inflate lag
A message that always fails keeps lag high, so the autoscaler adds pods that cannot help. Cap retries, move failures to retry topics with delays and then a DLQ, and alert on DLQ growth.
10 Failure handling
Downstream provider outage
The LLM API returns 503 for 10 minutes. Workers trip a circuit breaker and stop consuming (or pause the worker via the API) instead of retrying in a tight loop. Lag grows, but the autoscaler must not add pods while the breaker is open; scaling is suppressed by a 'downstream unhealthy' metric. When it recovers, the backlog drains within the provider's limit.
Pod killed without graceful shutdown (OOM, node crash)
Offsets for in-flight jobs were not committed, so they are redelivered. Idempotency keys make re-processing safe. Commit offsets only after the side effect succeeds.
Autoscaler or KEDA down
Replicas stay at their current count; nothing breaks immediately, but bursts are not absorbed. Alert on oldest-message age as a backstop, and keep sensible minReplicas for critical queues.
11 What impresses
- Explains why CPU is the wrong signal for I/O-bound workers and computes replicas with Little's Law
- Caps replicas by partitions and by downstream limits, and says that extra pods just make 429s faster
- Handles rebalance storms with cooperative-sticky assignment and static membership
- Plans for predictable peaks with scheduled pre-scaling
- Commits offsets only after side effects and uses idempotency keys for spot interruptions
- Suppresses scaling when the downstream circuit breaker is open
12 Mistakes
- Using CPU-based autoscaling for workers that wait on network calls
- Setting maxReplicas above the Kafka partition count
- Scaling workers past the downstream rate limit
- Committing offsets before processing finishes, losing jobs on scale-down
- No cooldown, causing flapping and constant rebalances
- Ignoring cold-start time for scale-from-zero on latency-sensitive queues
13 Follow-ups
Lessons this design uses
Practice questions
- During a Big Billion Days sale, consumer lag on your order-events topic grows from zero to 40 million messages. Walk me through what you check and what you do.
- An upstream service pushes events into your queue at 20,000 per second, but your workers can process only 5,000 per second for the next hour. What happens and how should the system behave?
- Your notification workers autoscale on CPU at 70%, but during a big campaign the queue grows to 5 million messages while CPU stays at 30%. What went wrong and what should you scale on?
- Users upload invoices that need OCR, which takes about 8 seconds each. Uploads spike 10x at month end. How do you design this so the spike does not take down the API?
Saved on this device. Create a free account to keep it on every device.
Practise this with Sam
A real mock interview, out loud, with an honest scorecard. Free to start.