Design problems
Platform & infrastructureMediumSDE-2 30 min

Design a queue-based autoscaling worker platform

Asked at
AmazonFlipkartMeeshoOpenAISwiggyUber

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

scale on backlog or oldest-message age, not CPULittle's Law: in-flight = arrival rate × processing timeKEDA driving HPA, scale-to-zeropod scaling vs node scaling (Karpenter / Cluster Autoscaler)Kafka partition count caps consumersdownstream rate limit as the true ceiling, shared token bucketstabilisation window, scale up fast and down slowcooperative-sticky rebalancingspot interruptions with graceful shutdown and idempotent jobscost per 1,000 jobs

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

Burst size2M jobs in ~5 minutesBulk catalogue upload or sale-start notification fan-out; arrival ≈ 6,700 jobs/s during the burst
Workers needed (LLM classifier)~400 concurrent jobs to drain 2M jobs in 1 hEach job ~0.7 s of LLM latency; 2M ÷ 3,600 s ≈ 555 jobs/s × 0.7 s ≈ 390 in flight (Little's Law). At 20 concurrent jobs per pod → ~20 pods
LLM throughput ceiling~417 jobs/s at a 20M tokens/min limitEach classification ~800 tokens; 20,000,000 ÷ 800 = 25,000 jobs/min ≈ 417 jobs/s, below the 555/s needed for a 1 h drain → 2M jobs take ~80 min. More pods cannot fix this; only a higher limit, smaller prompts or batch APIs can
Kafka partition capmax useful consumers = partitionsTopic with 48 partitions → at most 48 consumer instances in a group; set maxReplicas ≤ 48 or raise partitions ahead of time
Spot savings~60-80% vs on-demandTypical spot discount on general-purpose instances; mixed 70% spot, 30% on-demand ≈ 45-55% lower bill overall
Cold start from zero~20-90 sPod scheduling + image pull (cached ~5 s, cold ~30 s) + app init + KEDA polling interval (~15-30 s); +60-120 s if a new node is needed

3 API

EndpointWhat 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}/statusBacklog, oldest message age, replicas desired/actual, throughput, ETA to drain, scaling events.
POST/v1/workers/{name}:pause | :resumePause consumption (e.g. downstream outage) without losing backlog; autoscaler holds replicas at 0.
POST/v1/workers/{name}/limitsChange 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_ok

Platform API is the interface; teams never hand-edit HPA objects. GitOps applies the rendered manifests.

queue metricsPrometheus
consumer_lag{topicgroup}oldest_message_agejobs_completed_totaljob_duration_seconds histogram

Lag via Kafka exporter or KEDA's Kafka scaler; processing rate derived from completions, needed for deadline-based scaling.

job idempotencyPostgres / Redis
job_id (PK)statusresult_refcompleted_at

Workers check-and-set before side effects so a re-delivered job after a kill is a no-op.

token budgetRedis
rl:{provider}:{model} token bucket

Shared limiter across all pods of a worker type so 50 pods do not exceed the provider limit together.

cost recordsClickHouse / OpenCost
workertsnode_typecapacity_typecpu_secondsjobs_done

Cost per 1,000 jobs per worker; drives right-sizing.

5 Architecture

CLIENTSEDGESERVICESASYNCDATAEXTERNALenqueue jobsdeclare workerScaledObjectread lagrate, oldest ageset replicaslaunch / drainpending podsconsumetake tokenscallidempotent writefailed jobsmetricsProducer servicesTeams (worker spec)Platform API (GitOps)KEDA + HPAKarpenter (nodes)Kafka / SQS queuesWorker podsRetry + DLQ topicsPrometheus metricsRedis token budgetPostgres idempotencyLLM / downstream APIsEC2 spot + on-demand
ClientEdge / gatewayService / workerQueue / streamCacheDatabase / storageAI / modelThird partyrequestasync (event / queue)

Hover a box to highlight its connections.

6 Request flows

A 2M-job burst arrives and drains

  1. 1A seller uploads 2M product images for LLM-based category tagging; the upload service writes jobs to topic catalog.classify (48 partitions).
  2. 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).
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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.
  8. 8Karpenter consolidates: drains under-used nodes (respecting PodDisruptionBudgets) and terminates them.

Spot node is reclaimed mid-job

  1. 1AWS sends a 2-minute interruption notice; Karpenter (via the interruption queue) cordons the node and starts draining it.
  2. 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.
  3. 3Jobs that did not finish are not committed, so Kafka redelivers them to another consumer after rebalance.
  4. 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

Q1How do you choose the partition count for a new topic if you expect 10x growth?
Q2How do you make sure payment notifications are never delayed by a bulk job burst?
Q3How would you autoscale GPU workers running a self-hosted model where a node takes 5 minutes to boot?
Q4What would you change if jobs take 30 minutes each?
Q5How do you measure and report cost per job per team?
Q6How do you test that a spot interruption causes no lost or duplicate work?

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.

Start free