Horizontal Pod Autoscaling: How to Build a Kubernetes Scaling Engine That Responds in Seconds, Not Minutes
Most teams configure Horizontal Pod Autoscaling and assume it works — until traffic spikes and their app crumbles. Learn how to engineer a production-grade HPA system that actually scales fast enough to matter.
TL;DR Quick Answer: Horizontal Pod Autoscaling (HPA) in Kubernetes scales your workloads based on observed metrics — but the default configuration is dangerously slow for production traffic spikes. To build a scaling engine that responds in seconds, you need custom metrics via KEDA or the External Metrics API, aggressive scaleUp policies, Metrics Server tuning, and a warm pod strategy. This guide walks through every layer of that system with real configuration and numbers.
Why Default Horizontal Pod Autoscaling Will Fail You at 3AM
Horizontal Pod Autoscaling is one of Kubernetes' most powerful primitives — and one of the most dangerously misunderstood. Teams configure a basic HPA object pointing at CPU utilization, deploy it to production, and consider the job done. Then a flash sale hits, a viral post lands, or a cron job triggers a downstream cascade — and within 90 seconds, your pods are saturated, your latency has spiked from 80ms to 4.2 seconds, and your users are already rage-tweeting.
The default HPA sync loop runs every 15 seconds. The default stabilization window is 5 minutes for scale-down and 3 minutes for scale-up. Kubernetes won't even schedule a new pod until the metrics breach the threshold for a sustained window. By the time your cluster reacts, the damage is done.
At Apargo, we've architected scaling systems for SaaS platforms handling millions of requests per day — and we've learned that production-grade Horizontal Pod Autoscaling requires deliberate engineering across metrics collection, policy configuration, and infrastructure warm-up. This guide gives you the full playbook.
Understanding the HPA Control Loop Architecture
How the Default HPA Controller Works
The HPA controller in Kubernetes runs as part of the kube-controller-manager. It watches HPA objects and periodically queries the Metrics API to compute the desired replica count using the following formula:
desiredReplicas = ceil[currentReplicas * (currentMetricValue / desiredMetricValue)]
For example, if you have 4 pods running at 80% CPU and your target is 50%, the controller computes:
desiredReplicas = ceil[4 * (80 / 50)] = ceil[6.4] = 7
This sounds clean in theory. In practice, the following bottlenecks destroy response time:
- Metrics Server scrape interval: Default is 60 seconds. You're already a minute behind.
- HPA sync period: Default is 15 seconds. Add this to the scrape delay.
- Pod scheduling latency: The scheduler needs to find a node with available capacity. If nodes are full, the Cluster Autoscaler must provision a new node — which takes 60–180 seconds on most cloud providers.
- Container startup time: Cold JVM startup, model loading, or heavy initialization can add 10–40 seconds before a pod is ready to serve traffic.
In the worst case, your total time-to-serve for a new pod under load is over 5 minutes. That's not autoscaling. That's a post-incident report waiting to happen.
Layer 1: Fixing Metrics Collection Latency
Tuning the Metrics Server
The first thing you need to do is reduce your Metrics Server scrape interval from the default 60 seconds down to 15 seconds. Deploy the Metrics Server with the following flags:
# metrics-server deployment args
containers:
- name: metrics-server
args:
- --metric-resolution=15s # Scrape every 15s instead of 60s
- --kubelet-preferred-address-types=InternalIP
- --kubelet-use-node-status-port
- --cert-dir=/tmp
This alone can reduce your metrics lag by 45 seconds. Combined with tuning the HPA sync period in kube-controller-manager:
# kube-controller-manager flags
--horizontal-pod-autoscaler-sync-period=10s # Default: 15s
--horizontal-pod-autoscaler-downscale-stabilization=120s # Default: 300s
Moving Beyond CPU: Custom and External Metrics
CPU utilization is a lagging indicator. By the time CPU spikes, your request queue is already full. The right signal for Horizontal Pod Autoscaling is request queue depth, active connections, or p95 latency — metrics that reflect actual load, not computational consequence.
To use custom metrics, you need to deploy a custom metrics adapter. The two most production-proven options are:
- Prometheus Adapter — Pulls metrics from Prometheus and exposes them via the
custom.metrics.k8s.ioAPI - KEDA (Kubernetes Event-Driven Autoscaling) — A more powerful operator that supports 50+ scalers including Kafka, RabbitMQ, Redis, HTTP, and more
Layer 2: KEDA for Event-Driven Horizontal Pod Autoscaling
KEDA is the most significant advancement in Kubernetes autoscaling in the last three years. It extends the HPA mechanism with a ScaledObject CRD that can trigger scaling based on virtually any external signal. At Apargo, we use KEDA extensively in event-driven microservice architectures and in our AI Greentick WhatsApp automation platform to scale message processing workers in real time.
Scaling on Kafka Consumer Lag
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: message-processor-scaler
namespace: production
spec:
scaleTargetRef:
name: message-processor-deployment
minReplicaCount: 2
maxReplicaCount: 50
pollingInterval: 10 # Check every 10 seconds
cooldownPeriod: 60 # Wait 60s before scaling down
triggers:
- type: kafka
metadata:
bootstrapServers: kafka-broker:9092
consumerGroup: whatsapp-message-processors
topic: inbound-messages
lagThreshold: "100" # Scale up when lag > 100 messages
offsetResetPolicy: latest
With this configuration, when inbound message lag exceeds 100 events, KEDA triggers a scale-up event within 10–15 seconds — compared to the 60–90 seconds you'd get with CPU-based HPA. In load testing, we measured a 73% reduction in scale-up trigger latency when switching from CPU metrics to Kafka lag-based scaling.
Scaling on HTTP Request Rate with KEDA HTTP Add-on
apiVersion: http.keda.sh/v1alpha1
kind: HTTPScaledObject
metadata:
name: api-gateway-scaler
namespace: production
spec:
hosts:
- api.yourdomain.com
scaleTargetRef:
deployment: api-gateway
service: api-gateway-svc
port: 8080
replicas:
min: 3
max: 100
scaledownPeriod: 90
targetPendingRequests: 50 # Scale when > 50 pending requests per pod
Layer 3: Aggressive ScaleUp Policies
Even with fast metrics, the HPA controller's default scaling behavior is conservative. Kubernetes applies rate-limiting to scaling decisions to avoid thrashing. You need to override this explicitly for scale-up events:
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: api-service-hpa
namespace: production
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: api-service
minReplicas: 3
maxReplicas: 80
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 60
- type: Pods
pods:
metric:
name: http_requests_per_second
target:
type: AverageValue
averageValue: "200"
behavior:
scaleUp:
stabilizationWindowSeconds: 0 # No stabilization delay on scale-up
policies:
- type: Percent
value: 100 # Double pod count per period
periodSeconds: 15
- type: Pods
value: 10 # Or add 10 pods per period
periodSeconds: 15
selectPolicy: Max # Use whichever is larger
scaleDown:
stabilizationWindowSeconds: 180 # Wait 3 minutes before scaling down
policies:
- type: Percent
value: 20 # Remove max 20% per period
periodSeconds: 60
selectPolicy: Min # Use the most conservative policy
The selectPolicy: Max on scale-up tells Kubernetes to pick whichever policy adds the most pods — giving you the most aggressive possible scale-up response. The selectPolicy: Min on scale-down is deliberately conservative to prevent flapping.
Layer 4: Node Readiness and the Cluster Autoscaler Problem
Even if your HPA fires in 10 seconds, your pods won't serve traffic until a node is available. If all nodes are at capacity, the Cluster Autoscaler must provision new nodes. On AWS EKS, this typically takes 90–150 seconds. On GKE Autopilot, it's closer to 60–90 seconds. Either way, you have a gap.
Strategy 1: Over-Provision Nodes with Pause Pods
Deploy low-priority "pause" pods that consume node capacity but do nothing. When real workloads scale up, the scheduler evicts the pause pods and immediately places new pods on existing nodes — no node provisioning required.
apiVersion: apps/v1
kind: Deployment
metadata:
name: cluster-overprovisioner
namespace: kube-system
spec:
replicas: 3
selector:
matchLabels:
app: cluster-overprovisioner
template:
metadata:
labels:
app: cluster-overprovisioner
spec:
priorityClassName: low-priority-overprovisioner
containers:
- name: pause
image: registry.k8s.io/pause:3.9
resources:
requests:
cpu: "1"
memory: "2Gi"
limits:
cpu: "1"
memory: "2Gi"
---
apiVersion: scheduling.k8s.io/v1
kind: PriorityClass
metadata:
name: low-priority-overprovisioner
value: -10
globalDefault: false
description: "Used for cluster overprovisioning pause pods"
This strategy alone can reduce pod-ready latency from 90–150 seconds down to 8–15 seconds for the first wave of scale-up events.
Strategy 2: Optimize Container Startup Time
A pod scheduled in 5 seconds that takes 40 seconds to initialize is still a 45-second response time. Profile and reduce your startup path:
- Use distroless or slim base images — reduces pull time by 40–60%
- Pre-pull images on nodes using a DaemonSet to cache your production images on every node
- Implement fast readiness probes — don't wait for full warm-up before marking a pod ready if you can serve partial traffic
- For JVM services: Use GraalVM native image or CRaC (Checkpoint/Restore) to reduce startup from 8–15 seconds to under 500ms
- For Python ML services: Pre-load models at node startup using an init container that populates a shared volume
Layer 5: Multi-Dimensional Scaling with Composite Metrics
Production workloads are never one-dimensional. A service might be CPU-light but memory-heavy, or handle bursty I/O without spiking CPU at all. Configure your Horizontal Pod Autoscaling to evaluate multiple metrics simultaneously:
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 65
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: 75
- type: External
external:
metric:
name: pubsub_subscription_num_undelivered_messages
selector:
matchLabels:
subscription: order-events-sub
target:
type: AverageValue
averageValue: "500"
When multiple metrics are configured, Kubernetes selects the maximum desired replica count across all metrics. This means if CPU says you need 6 pods but the queue depth says you need 12, you'll get 12. This is exactly the behavior you want in production.
Observability: You Can't Tune What You Can't See
Horizontal Pod Autoscaling decisions are only as good as your observability. Set up dashboards that track:
Related Articles
Explore more insights from our engineering and product teams.
