Back to Blog
April 24, 20241,000 views

Autoscaling in System Design: How Systems Grow and Shrink with Demand (Visualized)

Autoscaling automatically adjusts the number or size of compute instances to match real-time workload, ensuring performance under spikes while cutting cost during quiet periods. This guide covers horizontal vs vertical scaling, metric-driven triggers, target tracking, cooldowns, Kubernetes HPA, and cloud ASGs β€” with live animations.

Autoscaling is the capability of a distributed system to automatically increase or decrease its compute capacity in response to changing traffic load, without human intervention. Rather than provisioning for peak demand at all times β€” which wastes money during quiet periods β€” or under-provisioning and hoping spikes never come, autoscaling lets your infrastructure breathe: it expands when load rises and contracts when load falls.

The economic argument is compelling: cloud resources are billed by usage, so a fleet that shrinks from 20 instances to 4 at 3 AM directly cuts your bill by 80% during those hours. The reliability argument is equally strong: a fleet that automatically adds capacity during a flash sale or viral traffic spike avoids the degradation and outages that fixed-size deployments suffer.

Horizontal vs Vertical Scaling

There are two fundamental dimensions along which you can scale a service. Horizontal scaling (scale-out / scale-in) adds or removes instances of the same size β€” you go from 3 servers to 8, then back to 2. Vertical scaling (scale-up / scale-down) keeps the instance count the same but resizes each instance β€” you upgrade from a 4-vCPU machine to a 16-vCPU machine when load rises, then back down when it falls. In modern cloud architectures, horizontal scaling dominates for stateless services because it is virtually unbounded, tolerates individual instance failures, and works naturally with load balancers.

Horizontal scale-out vs vertical scale-up
Left panel: horizontal scaling adds instances as load grows. Right panel: vertical scaling resizes the single instance. Watch both respond to the same rising load.
Horizontal ScalingVertical Scaling
MechanismAdd / remove instancesResize existing instance
Upper boundVirtually unlimitedLargest available machine size
Fault toleranceHigh β€” one instance death is minorLow β€” single point of failure
Downtime requiredNo β€” instances added liveOften yes β€” instance restart/stop
Cost granularityFine β€” pay per instanceCoarse β€” large jumps between sizes
Best forStateless services, APIs, microservicesStateful DBs, caches, legacy apps

Metric-Driven Triggers

Autoscaling decisions are driven by metrics β€” observable signals that indicate whether the current fleet is over- or under-provisioned. The most common triggers are:

CPU utilization β€” when average CPU across the fleet exceeds a threshold (e.g. 70%), add instances; when it drops below 30%, remove them. Simple and available on every cloud platform, but CPU can lag behind user-facing latency, so it is not always the best signal.

Requests per second (RPS) / concurrency β€” directly measures application load. Works well for HTTP services where each request is roughly equal in cost. Many API gateways and load balancers expose this natively.

Queue depth β€” for async workers, the length of a job queue (SQS, Kafka consumer lag, RabbitMQ queue size) is the ideal trigger. A growing queue means workers cannot keep up; scale out. An empty queue means idle workers; scale in.

Custom metrics β€” anything you can emit: active WebSocket sessions, cache hit rate, in-flight payment transactions, GPU memory usage. Kubernetes supports custom and external metrics via the metrics.k8s.io API, and AWS CloudWatch accepts arbitrary metrics via PutMetricData.

Scaling Policies: Target Tracking, Step, Scheduled, Predictive

Target tracking scaling is the simplest and most recommended policy for most workloads. You specify a target value for a metric (e.g. keep average CPU at 60%), and the autoscaler continuously adjusts capacity to hold that target. It behaves like a thermostat: it calculates the number of instances needed to reach the target and smoothly converges to it. AWS Auto Scaling Groups and Kubernetes HPA both implement target tracking.

Step scaling lets you define bracketed thresholds β€” add 2 instances when CPU is 70–80%, add 5 when CPU is above 80%. This gives finer control over scale-out aggression for predictable, non-linear workloads but requires manual tuning of the brackets.

Scheduled scaling pre-emptively changes capacity at a fixed time. If you know every Monday morning at 9 AM your fleet needs to be at 20 instances, you schedule a scale-out action for 8:55 AM. This avoids the 2–5 minute lag of reactive scaling during a predictable daily ramp.

Predictive scaling (AWS) uses machine-learning models trained on your historical CloudWatch data to forecast load 24–48 hours ahead and pre-scales before the load arrives. It combines the best of scheduled and reactive scaling automatically.

Target-tracking autoscaling keeping CPU at 60%
The autoscaler continuously adjusts the fleet size to hold CPU near the 60% target. Watch the instance count rise and fall as load changes.

Cooldowns and Thrashing

Thrashing is the autoscaling failure mode where the system oscillates β€” scaling out, then immediately scaling back in, then out again β€” faster than instances can stabilise. Each launch and termination takes 1–3 minutes, costs money, and disrupts in-flight requests. Thrashing happens when the scale-in threshold is too close to the scale-out threshold, or when metric noise causes repeated triggers.

The primary defence is the cooldown period: a mandatory pause after a scaling event during which no further scaling actions occur. AWS ASGs default to a 300-second cooldown. Kubernetes HPA uses stabilizationWindowSeconds (default: 300 s for scale-in, 0 s for scale-out). During the cooldown the controller waits for the newly launched instances to fully absorb traffic before re-evaluating the metric. A sensible rule: the cooldown should be longer than your instance boot + warmup time. Use fast-launching, pre-baked AMIs or container images to keep boot time under 60 seconds so your cooldown can be tight.

Additional techniques: use a scale-in delay (wait N periods below threshold before removing instances), apply metric smoothing (average over 2–5 data points rather than reacting to a single sample), and always set a minimum instance count to prevent the fleet from scaling all the way to zero under a quiet period only to need a cold start during a sudden spike.

Live Autoscaling: Load Spikes, Scale-Out, and Scale-In

Reactive autoscaling: load spikes and fleet adjustment
Incoming request rate spikes, triggering scale-out. During cooldown no new scaling happens. As load drops, scale-in removes excess instances.

Kubernetes Horizontal Pod Autoscaler (HPA)

Kubernetes ships a first-class autoscaling controller called the Horizontal Pod Autoscaler (HPA). It watches a deployment (or replica set) and adjusts the replicas field based on observed metric values. By default it polls the metrics-server every 15 seconds and recomputes the desired replica count as:

desiredReplicas = ceil(currentReplicas Γ— (currentMetricValue / desiredMetricValue))

For example: 4 replicas, CPU at 80%, target 50% β†’ ceil(4 Γ— 80/50) = ceil(6.4) = 7 replicas. HPA supports CPU, memory, custom metrics (via custom.metrics.k8s.io), and external metrics (e.g. SQS queue depth via KEDA). A stabilizationWindowSeconds on the scale-down path prevents premature shrinking after brief dips.

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: api-server-hpa
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: api-server
  minReplicas: 2
  maxReplicas: 20
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 60   # target-tracking: keep avg CPU at 60%
    - type: Resource
      resource:
        name: memory
        target:
          type: AverageValue
          averageValue: 512Mi
  behavior:
    scaleDown:
      stabilizationWindowSeconds: 300  # wait 5 min before scaling down
      policies:
        - type: Pods
          value: 2                      # remove at most 2 pods per period
          periodSeconds: 60
    scaleUp:
      stabilizationWindowSeconds: 0    # react immediately to spikes
      policies:
        - type: Percent
          value: 100                   # allow doubling per period
          periodSeconds: 60

AWS Auto Scaling Groups

On AWS, Auto Scaling Groups (ASGs) are the equivalent primitive for EC2 instances. An ASG holds a launch template (AMI, instance type, security groups, user-data) and maintains the desired number of instances within a configured min/max range. Scaling policies attach to the ASG and react to CloudWatch alarms. A typical setup looks like:

1. Target tracking β€” attach a TargetTrackingScaling policy targeting 60% CPU. AWS creates and manages the CloudWatch alarms internally, adds instances when the target is breached high, and removes them when consistently low.

2. Scheduled actions β€” a ScheduledAction sets desired capacity to 20 every weekday at 08:50 UTC, before the morning ramp.

3. Lifecycle hooks β€” an autoscaling:EC2_INSTANCE_LAUNCHING hook runs a health-check script before marking the instance InService, preventing premature traffic to a not-yet-warmed-up instance.

Pair an ASG with an Application Load Balancer and the ALB automatically registers new instances as targets and deregisters terminating ones, with connection draining to let in-flight requests complete.

Scaling PolicyTrigger mechanismBest use caseCloud support
Target trackingMetric vs desired target valueGeneral-purpose stateless servicesAWS ASG, Kubernetes HPA
Step scalingThreshold brackets with custom stepsNon-linear load patterns, fine controlAWS ASG, Cloud Run
Scheduled scalingTime-based cron expressionPredictable daily/weekly peaksAWS ASG, GCP MIG, K8s CronJob + HPA
Predictive scalingML forecast of historical loadRecurring traffic patternsAWS ASG (Predictive Scaling)
KEDA / Event-drivenExternal event source (queue, topic)Async workers, batch jobsKubernetes + KEDA

Common Pitfalls and Best Practices

Slow boot times kill autoscaling: if your instances take 8 minutes to start, you cannot react to a spike that is already degrading users. Bake as much as possible into your AMI or container image; leave only environment-specific configuration to startup scripts. Target sub-60-second readiness.

Scaling stateful services is dangerous: autoscaling works best with stateless services. If your instances hold session state in local memory or on local disk, scale-in terminates nodes with live sessions. Use sticky sessions sparingly; move session state to Redis or a shared database.

Never set minReplicas to 0 for critical services: a fleet at zero has no instance to receive the first request that would trigger scale-out. The cold-start latency during that first request is user-facing. Keep a minimum of 2 (for redundancy across availability zones).

Monitor scale events as first-class signals: emit a metric or log entry for every scale-out and scale-in event. Frequent scale-outs indicate your baseline is too small; frequent oscillations indicate your cooldown is too short or thresholds are too tight. Feed these events into your on-call runbooks.

Frequently Asked Questions

What is the difference between autoscaling and load balancing?

Load balancing distributes traffic across a fixed pool of existing instances. Autoscaling changes how many instances are in that pool. They are complementary: the load balancer spreads load evenly across whatever instances are currently running, while the autoscaler ensures enough instances exist to handle the total load. In AWS, an ALB paired with an ASG is the canonical combination: the ALB handles distribution, the ASG handles fleet size.

How quickly does autoscaling react to a sudden traffic spike?

End-to-end latency from spike to serving instance is typically 2–5 minutes for EC2-based ASGs (metric evaluation delay + instance boot + health check). Kubernetes pod scaling is faster β€” 15–30 seconds for the HPA evaluation cycle plus container pull and startup β€” but still not instantaneous. For truly instantaneous bursting, use serverless (AWS Lambda, Cloud Run) which scales from 0 to thousands of concurrent executions in under a second, or pre-scale via scheduled actions before the expected spike.

Should I use CPU or RPS as my autoscaling metric?

CPU is universally available and works well for compute-bound services. However, CPU can lag 30–60 seconds behind actual user-facing load, and a service spending most of its time waiting on I/O (database calls, external APIs) may have low CPU even when heavily loaded. Prefer RPS or request latency (p95 response time) when your service is I/O-bound, and queue depth for async workers. The golden rule: scale on the metric that most directly represents the constraint that degrades user experience.

Autoscaling is not a replacement for capacity planning β€” it is the execution layer. Know your traffic patterns, set your min/max bounds deliberately, and let the scaler handle the rest. A well-tuned autoscaler is invisible: users never notice the fleet growing behind them.

β€” alokknight Engineering