Kubernetes HPA: How It Works and How to Stop Flapping
DevOps Engineering

Kubernetes HPA: How It Works and How to Stop Flapping

The HPA formula, behavior YAML, custom metrics, KEDA, and fixes for scaling flaps and unknown targets.

Arthur Azrieli

0 min read

The Kubernetes Horizontal Pod Autoscaler (HPA) adds or removes pod replicas for a Deployment, StatefulSet or other scalable workload based on a metric such as CPU utilization. Every 15 seconds it compares the current metric to your target, computes ceil(currentReplicas × current / target), and scales within your minReplicas and maxReplicas.

That is the whole idea. Most of the trouble comes later, when replica counts start bouncing up and down, targets show <unknown>, or a GitOps tool keeps resetting the replica count. This guide covers how the algorithm really behaves (including the parts most tutorials skip), copy-paste manifests for the common cases, the behavior settings that stop scaling flaps, how HPA fits with VPA, KEDA, Karpenter and Argo Rollouts, and a troubleshooting table built from the actual controller error messages.

Versions referenced: Kubernetes 1.37 (the current release in October 2026), metrics-server v0.9.0 and KEDA v2.21. The autoscaling/v2 API used throughout has been stable since Kubernetes 1.23, so everything here works on any supported cluster unless a section says otherwise.

Kubernetes autoscalers compared: HPA, VPA, KEDA and node autoscalers

"Kubernetes autoscaling" means four different things, and mixing them up causes most bad designs. The HPA only changes the number of pods. It never changes pod size and never adds nodes.

AutoscalerWhat it changesTypical signalUse it when
Horizontal Pod Autoscaler (HPA)Replica count of a workloadCPU or memory utilization, custom or external metricsStateless services where more pods means more capacity
Vertical Pod Autoscaler (VPA)CPU and memory requests of each podHistorical usageRight-sizing requests, or workloads that cannot scale out
KEDAReplica count, including to and from zero (it creates and manages an HPA for you)Event sources: queue length, Kafka lag, cron, Prometheus queries, cloud servicesQueue consumers and event-driven workers
Cluster Autoscaler or KarpenterNumber and type of nodesPending pods that cannot be scheduledAlways, if the HPA can create more pods than your nodes can hold

The usual production setup is HPA (or KEDA) for pods plus Cluster Autoscaler or Karpenter for nodes. If the node layer is missing, the HPA happily creates pods that sit in Pending, and you get the cost of a scale-up decision without the capacity. Our guide on setting up Kubernetes autoscaling without cost surprises covers the node side.

How the Horizontal Pod Autoscaler algorithm works

The HPA is a control loop inside kube-controller-manager. It does not react to events. It wakes up on an interval (--horizontal-pod-autoscaler-sync-period, 15 seconds by default), reads metrics, and computes a desired replica count:

desiredReplicas = ceil( currentReplicas × currentMetricValue / desiredMetricValue )

Example: 4 replicas averaging 90% CPU utilization against a 60% target gives ceil(4 × 90 / 60) = ceil(6.0) = 6 replicas. At 45% utilization it gives ceil(4 × 45 / 60) = 3.

For resource metrics, "utilization" is usage divided by the container's request, not its limit and not node capacity. A pod requesting 250m CPU and using 200m is at 80%. This single fact explains a large share of HPA surprises: change the request and you change the scaling math, even though nothing about the traffic changed.

Flow chart of the HPA control loop: every 15 seconds it reads metrics (utilization is usage divided by request), computes ceil(4 x 90 / 60) = 6, does nothing if the ratio is within 10% of target (54% to 66% for a 60% target), and otherwise scales to 6 replicas within minReplicas and maxReplicas.
The HPA recomputes desired replicas on a fixed interval and skips any change that falls inside the 10% tolerance.

The details that shape real behavior

The formula is the easy part. These rules, all from the Kubernetes documentation, decide what actually happens:

  • Tolerance. If the ratio current / target is within 10% of 1.0, the HPA does nothing. With a 60% CPU target, anything between 54% and 66% is a no-op. This dead band is your first protection against jitter.
  • Unready pods are set aside. When scaling on CPU, pods that are not yet ready, or whose latest metric sample predates readiness, are excluded from the average. If the result would be a scale up, those pods are then assumed to use 0% of the target, which dampens the scale up.
  • Missing metrics are treated conservatively. Pods with no metric are assumed to use 100% of the target when the HPA is considering a scale down, and 0% when considering a scale up. If that reverses the direction or lands inside the tolerance, the HPA skips the action.
  • Startup windows. --horizontal-pod-autoscaler-initial-readiness-delay (default 30 seconds) and --horizontal-pod-autoscaler-cpu-initialization-period (default 5 minutes) control how the controller treats CPU samples from freshly started pods. These are control plane flags, so on EKS, GKE or AKS you cannot change them. Design the workload around the defaults instead.
  • Multiple metrics take the maximum. With CPU and a requests-per-second metric, the HPA computes a desired count for each and uses the highest. If one metric cannot be fetched and the others suggest a scale down, the scale down is skipped. Scale up still works.
  • Status shows the raw value. kubectl get hpa reports the original average, not the adjusted one used for the decision. That is why the TARGETS column can look like it should trigger a scale and nothing happens.
  • Scale down is smoothed by default. The HPA keeps the highest recommendation from the last 5 minutes before scaling down. That default stabilization window is why replicas linger after a spike.

Prerequisites: metrics-server and resource requests

Resource metrics (CPU and memory) come from the metrics.k8s.io API, which metrics-server provides. Many managed clusters ship it, many do not. Check first:

kubectl get apiservice v1beta1.metrics.k8s.io
kubectl top pods -n production

If kubectl top fails, install metrics-server:

kubectl apply -f https://github.com/kubernetes-sigs/metrics-server/releases/latest/download/components.yaml

Then make sure every container in the target pods has a CPU request (and a memory request if you scale on memory). The HPA computes utilization per pod across all its containers, so a single sidecar without a request breaks CPU utilization scaling for the whole workload. If your requests are guesses, fix them before tuning the HPA. Our guide to Kubernetes requests and limits shows how to size them from real usage.

Create your first HPA

The fastest way is kubectl autoscale. Recent kubectl versions use --cpu with a percentage (the older --cpu-percent flag is deprecated):

kubectl autoscale deployment api -n production --min=3 --max=20 --cpu=60%

For anything you run in production, keep the HPA in version control as YAML instead:

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: api
  namespace: production
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: api
  minReplicas: 3
  maxReplicas: 20
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 60

Watch it work:

kubectl get hpa api -n production --watch
kubectl describe hpa api -n production

describe is where the useful information lives: current metrics, the three status conditions (AbleToScale, ScalingActive, ScalingLimited) and recent scaling events with reasons.

Remove spec.replicas when the HPA owns the count

The Kubernetes docs recommend removing spec.replicas from the Deployment once an HPA manages it. Otherwise every kubectl apply, Helm upgrade or Argo CD sync resets the replica count to the value in Git, the HPA scales it back, and you get a sawtooth that looks exactly like flapping. In Helm charts, the common pattern is to render replicas only when autoscaling is disabled. In Argo CD, you can also add /spec/replicas to ignoreDifferences for the Deployment.

HPA metric types: resource, container, pods, object and external

The autoscaling/v2 API supports five metric source types. Pick the one that measures the real bottleneck, not the one that is easiest to wire up.

TypeWhat it measuresNeedsGood for
ResourceCPU or memory across the whole pod, as utilization or average valuemetrics-serverCPU-bound web services
ContainerResourceCPU or memory of one named containermetrics-server, Kubernetes 1.30 or newer (stable)Pods with sidecars (service mesh proxies, log shippers) that distort the pod average
PodsA custom per-pod metric averaged across podsA custom metrics adapter such as Prometheus AdapterRequests per second or in-flight requests per pod
ObjectA metric describing another Kubernetes objectA custom metrics adapterRequests per second on an Ingress
ExternalA metric from outside the clusterAn external metrics adapter (KEDA provides one)Queue depth, Pub/Sub backlog, Kafka lag

Scaling only the application container when a sidecar is present:

metrics:
  - type: ContainerResource
    containerResource:
      name: cpu
      container: app
      target:
        type: Utilization
        averageUtilization: 60

Scaling on a per-pod custom metric (for example, exposed by Prometheus Adapter as http_requests_per_second):

metrics:
  - type: Pods
    pods:
      metric:
        name: http_requests_per_second
      target:
        type: AverageValue
        averageValue: "50"

A few rules of thumb from running these in production:

  • CPU is a good signal when traffic turns directly into CPU work: serialization, compression, TLS, rendering.
  • Memory is a poor scaling signal for most runtimes. The JVM, Go, Node.js and Python rarely give memory back quickly after load drops, so a memory-based HPA scales up easily and then refuses to scale down. Use it only when memory per pod clearly falls as you add pods.
  • Requests per second or in-flight requests beat CPU for I/O-bound APIs that spend their time waiting on databases or other services.
  • Queue depth or lag is the right signal for workers. A worker blocked on a slow dependency shows low CPU while the queue grows, so CPU scaling never fires. Use KEDA here.

Why HPAs flap, and how to stop it

Flapping means the replica count oscillates: up on a spike, down a minute later, up again. Users feel it as latency spikes during scale ups, connection churn during scale downs, and a cluster that never settles. The causes are almost always one of these:

  1. Requests set too low. A pod that normally uses 300m with a 100m request sits at 300% utilization at idle. Small traffic changes swing the ratio wildly. Fix the request first.
  2. A noisy metric. Per-second request rates, garbage collection pauses and batchy traffic produce sharp short peaks. The HPA sees them every 15 seconds and reacts.
  3. Startup CPU spikes. JIT compilation, cache warm-up and connection pool creation make new pods look overloaded, which triggers more scale up, which starts more pods.
  4. Scale down that is too fast. Removing many pods at once pushes the survivors over the target, and the HPA adds them back.
  5. A target with no headroom. A 90% CPU target leaves nothing for the minute or two it takes a new pod to become ready.
  6. Something else is changing replicas. A GitOps sync resetting spec.replicas, two HPAs pointing at the same Deployment, or KEDA and a hand-written HPA on the same target.

Causes 1, 5 and 6 are configuration errors you fix in the workload. Causes 2, 3 and 4 are what the behavior field exists for.

Two panels splitting six causes of HPA flapping: fix in the workload (requests set too low, a target with no headroom, something else changing replicas) and tame with the behavior field (a noisy metric, startup CPU spikes, scale down that is too fast).
Half of flapping is a configuration error in the workload; the other half is what scaling behavior settings are for.

HPA behavior: stabilization windows and scaling policies

The behavior field lets you control scale up and scale down separately. Each direction has a stabilization window, one or more rate-limiting policies, and a selectPolicy that decides which policy wins. Fields you do not set are merged with these defaults:

behavior:
  scaleDown:
    stabilizationWindowSeconds: 300
    policies:
      - type: Percent
        value: 100
        periodSeconds: 15
  scaleUp:
    stabilizationWindowSeconds: 0
    policies:
      - type: Percent
        value: 100
        periodSeconds: 15
      - type: Pods
        value: 4
        periodSeconds: 15
    selectPolicy: Max

In words: by default the HPA can double the replica count (or add 4 pods, whichever is more) every 15 seconds with no delay, and can remove every pod above minReplicas in one step once the 5-minute scale-down window has passed. That is aggressive in both directions.

What each field does

  • stabilizationWindowSeconds: the HPA looks at all recommendations made during this window and uses the most conservative one. For scale down that is the highest recommendation, so a brief dip does not remove pods. For scale up it is the lowest, so a brief spike does not add pods.
  • policies: rate limits. type: Pods, value: 4, periodSeconds: 60 means at most 4 pods per minute. type: Percent, value: 10, periodSeconds: 60 means at most 10% of current replicas per minute (fractions are rounded up, so 10% of 72 replicas allows 8).
  • selectPolicy: Max picks the policy allowing the biggest change, Min the smallest, and Disabled turns off scaling in that direction entirely.
  • tolerance (per direction): overrides the cluster-wide 10% dead band for this HPA. It is beta in Kubernetes 1.35 and 1.36 and generally available from 1.37, so check your cluster version before relying on it.
Two bar charts of recommendations over a stabilization window: for scale down the highest recommendation wins so a brief dip removes no pods, and for scale up the lowest wins so a brief spike adds no pods.
A stabilization window ignores short dips and spikes by acting on the most conservative recent recommendation.

Anti-flap profiles you can copy

Starting points, not final answers. Tune them with the load test in the next section.

WorkloadScale upScale downWhy
Latency-sensitive API, fast startup (under 30 seconds)No window, max of 100% or 4 pods per 60 seconds300 second window, 20% per 60 secondsReact fast to real load, shed capacity slowly
Slow-starting service (JVM, large caches)60 second window, 50% per 60 seconds600 second window, 10% per 60 secondsStop startup spikes from compounding; keep warm pods
Spiky, bursty trafficNo window, 100% per 30 seconds900 second window, 1 pod per 120 secondsBursts return; keeping capacity is cheaper than churn
Batch or queue worker on CPUNo window, 4 pods per 60 seconds300 second window, 25% per 60 secondsUsually better on KEDA with queue depth

The latency-sensitive API profile as YAML:

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: api
  namespace: production
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: api
  minReplicas: 3
  maxReplicas: 30
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 60
  behavior:
    scaleUp:
      stabilizationWindowSeconds: 0
      selectPolicy: Max
      policies:
        - type: Percent
          value: 100
          periodSeconds: 60
        - type: Pods
          value: 4
          periodSeconds: 60
    scaleDown:
      stabilizationWindowSeconds: 300
      selectPolicy: Min
      policies:
        - type: Percent
          value: 20
          periodSeconds: 60
        - type: Pods
          value: 2
          periodSeconds: 60

With selectPolicy: Min on scale down, the HPA removes whichever is smaller, 20% or 2 pods, per minute. At 30 replicas that is 2 pods a minute, so once the 5-minute window has passed it takes about 13 more minutes to return to 3. That is deliberate: slow scale down is the cheapest insurance against flapping.

Line chart of replicas after load drops: the count holds at 30 during the 300 second window, then steps down 2 pods per minute for about 13 minutes until it reaches 3, because selectPolicy Min picks 2 pods over 20%.
Holding capacity for five minutes and then shedding 2 pods a minute is cheap insurance against flapping.

How to pick the target utilization

The target is really a headroom setting. Ask how much traffic can grow in the time it takes a new pod to become ready and serve. If traffic can rise 50% in the two minutes it takes to start a pod, existing pods need room to absorb that, so a target near 100 / 1.5, about 65% of the point where latency degrades, is the ceiling. If your service degrades at 80% CPU, that gives roughly 50% to 55%.

Most CPU-bound services land between 50% and 70%. Below 50% you pay for a lot of idle capacity. Above 75% you are betting that scale ups always finish before the next spike.

Test HPA behavior before production traffic does

Do not trust an HPA you have not watched under load. In a staging environment with realistic requests:

# terminal 1: decisions and events
kubectl get hpa api -n staging --watch
kubectl get events -n staging --field-selector involvedObject.name=api --watch

# terminal 2: pods coming and going
kubectl get pods -n staging -l app=api --watch

# terminal 3: load from outside the cluster for 10 minutes, then stop
hey -z 10m -c 100 https://staging.example.com/api/health-heavy

Record five things: time to first scale up, replicas added per minute, whether new pods were ready before the next decision, whether latency recovered, and how replicas stepped down after the load stopped. If replicas climb past what the load needs, your scale up is outrunning readiness. If they drop and come straight back, slow the scale down.

HPA with VPA, KEDA, Karpenter and Argo Rollouts

HPA and VPA

Do not let both act on the same resource metric. If the HPA scales on CPU utilization and VPA raises CPU requests, utilization drops, the HPA removes pods, utilization rises, and the two loops chase each other. Safe combinations are VPA in recommendation-only mode (updateMode: "Off") to inform your requests, or HPA on a custom metric such as requests per second while VPA manages CPU and memory.

Loop diagram: VPA raises CPU requests, utilization drops, the HPA removes pods, utilization rises, and the cycle repeats. Safe combinations: VPA in recommendation mode with updateMode Off, or HPA on requests per second with VPA on CPU and memory.
Give the HPA and VPA different signals, or keep VPA in recommendation mode, so they never fight over one metric.

HPA and KEDA

KEDA is not a competitor to the HPA. A KEDA ScaledObject creates and manages an HPA for you and feeds it external metrics from its 70+ built-in scalers, plus it handles activation from zero replicas. Never point your own HPA and a KEDA ScaledObject at the same Deployment. Choose KEDA when the signal lives outside the pod (queues, streams, schedules) or when you need scale to zero.

HPA and node autoscaling

When the HPA adds pods that do not fit, they stay Pending until Cluster Autoscaler or Karpenter adds a node, which commonly takes one to several minutes. Your scale-up speed is therefore the slower of pod startup and node provisioning. If that is too slow for your traffic pattern, raise minReplicas, lower the target, or keep a little spare node capacity with low-priority placeholder pods that real workloads can preempt (see our guide to Kubernetes PriorityClasses).

HPA during rollouts

During a rolling update the HPA scales the Deployment, and the Deployment controller spreads replicas across old and new ReplicaSets. Startup CPU from new pods can trigger scale ups mid-rollout, so the slow-starting profile above helps here too. If you use progressive delivery, point scaleTargetRef at the Rollout instead of a Deployment (apiVersion: argoproj.io/v1alpha1, kind: Rollout). Our Argo Rollouts guide covers how canary and blue-green steps interact with the HPA.

Can the HPA scale to zero?

Only for object and external metrics, and only with the HPAScaleToZero feature gate. That gate was alpha (off by default) from Kubernetes 1.16 to 1.36 and became beta, on by default, in 1.37. CPU and memory cannot scale to zero because they can only be measured on running pods. On older clusters, or for anything event driven, use KEDA, which has supported scale to zero for years.

HPA troubleshooting: error messages and fixes

These are the messages the HPA controller actually emits, as seen in kubectl describe hpa.

Symptom or messageCauseFix
TARGETS shows <unknown>/60%No metrics yet, metrics-server missing, or a container without a requestWait one or two minutes after creation; run kubectl top pods; add requests to every container
failed to get cpu utilization: missing request for cpu in container X of Pod YA container (often a sidecar or init-injected proxy) has no CPU requestAdd a CPU request to every container, or switch to ContainerResource
unable to get metrics for resource cpu: ... with reason FailedGetResourceMetricThe metrics.k8s.io API is unavailableCheck kubectl get apiservice v1beta1.metrics.k8s.io and the metrics-server pod logs
did not receive metrics for targeted pods (pods might be unready)Pods are crash looping or never become readyFix readiness first; see our guide to liveness probes without restart loops
ScalingLimited True TooManyReplicasThe desired count hit maxReplicasRaise maxReplicas only if downstream systems (database, cache, APIs) can take it
ScaleDownStabilized while load is lowThe scale-down window is holding the highest recent recommendationExpected. Shorten the window only if idle cost matters more than churn
Replicas jump back to a fixed number after every deployspec.replicas in the manifest overrides the HPA on each apply or syncRemove spec.replicas, or ignore it in Argo CD
Memory HPA never scales downThe runtime keeps memory after load dropsScale on CPU or a work metric instead
More pods, same latencyThe bottleneck is a database, lock or external APICap maxReplicas at downstream capacity, add pooling and backpressure

Production HPA checklist

  • Every container in the target has a CPU request based on measured usage.
  • spec.replicas is removed from the Deployment manifest, or ignored by your GitOps tool.
  • minReplicas is at least 2 (3 for multi-zone services) and covers normal traffic.
  • maxReplicas is set from downstream capacity and node budget, not from a round number.
  • The metric matches the bottleneck: CPU for compute, requests per pod for I/O-bound APIs, queue depth (via KEDA) for workers.
  • Scale up is faster than scale down, and scale down has a window of at least 300 seconds.
  • Readiness probes reflect real readiness, and startup time is known.
  • A node autoscaler is in place and its provisioning time is part of your scale-up budget.
  • VPA is not changing the same resource the HPA scales on.
  • You have watched the HPA through a load test and a scale down.

Kubernetes HPA FAQ

Is HPA useful for Kubernetes?

Yes, for stateless workloads where more pods means more capacity. It lets you run fewer replicas at quiet times and absorb peaks without manual intervention. It is the wrong tool for stateful systems that cannot simply add replicas, and for workloads whose bottleneck is outside the pod.

What is a good HPA CPU threshold?

Most CPU-bound services work well between 50% and 70% of the CPU request. Use the headroom calculation above: the slower your pods and nodes start, and the faster your traffic grows, the lower the target should be.

What is the difference between HPA and VPA?

The HPA changes how many pods run. VPA changes how much CPU and memory each pod requests. They can work together if they act on different signals, for example HPA on requests per second and VPA on resources, or VPA in recommendation mode only.

What is the difference between KEDA and HPA?

KEDA builds on the HPA. It creates an HPA for you, feeds it metrics from event sources like queues and streams, and adds scale to zero. Use plain HPA for CPU or custom metrics on services; use KEDA for event-driven workers.

How often does the HPA check metrics?

Every 15 seconds by default. Metrics-server itself scrapes kubelets on its own interval, so the data the HPA sees can be somewhat older than that.

Why does my HPA show unknown targets?

Either metrics are not available yet (normal for a minute or two after creation), metrics-server is missing or broken, or at least one container in the pods has no request for the resource you scale on. kubectl describe hpa shows which one.

Why is my HPA not scaling down?

The 5-minute default scale-down stabilization window, a metric still above the target minus the 10% tolerance, a memory metric that never drops, or a second metric that keeps the maximum recommendation high. Check the conditions and events in kubectl describe hpa.

Can I use HPA and a fixed replica count in Helm?

Not on the same object. Render replicas in the Deployment only when autoscaling is disabled, so a Helm upgrade does not reset the HPA's decision.

If your autoscaling still behaves unpredictably after this, the cause is usually in the workload or the node layer rather than the HPA itself. MeteorOps engineers tune this for startups as part of Kubernetes consulting.