Skip to main content

Kubernetes 1.37 Kills the Last Idle Pod: Native HPA Scale-to-Zero vs KEDA for Sleeping Apps

12 min readDora NodaDora Noda
Share
On this page

Kubernetes can now turn the last light off. In v1.37 — released August 26, 2026 under the codename "Garhwal" — the HorizontalPodAutoscaler graduated scale-to-zero support to beta and enabled it by default. An HPA driving an idle Deployment from an object or external metric can shed every replica, sit at zero, and come back when the metric moves.

No KEDA ScaledObject, no custom operator, no alpha feature gate on the controller manager. Set minReplicas: 0 and it just works — for the workloads it actually covers.

If you run a self-hosted git-push platform, that one line of YAML is aimed directly at your cheapest tier: the sleeping app that wakes on request. But the honest version of this story has a split ending. For queue workers, native scale-to-zero really does retire KEDA from the idle path. For HTTP preview apps — the actual sleeping tier of every PaaS — it does not, because Kubernetes Services still don't buffer requests while zero pods are ready. The verdict, up front:

WorkloadVerdict
Queue / batch workers scaling on queue depth or another external metricUse native HPA minReplicas: 0; drop KEDA from this path
Cron, ScaledJob workloads, and any of KEDA's 60+ event scalers beyond plain metricsKeep KEDA; native HPA doesn't cover triggers, only metrics
HTTP apps that must wake on an incoming requestYou still need a buffering layer (KEDA HTTP add-on interceptor, Knative activator, or equivalent); native HPA alone drops the first request on the floor
Tenant-facing sleep/wake SLA on betaNot yet — run it for internal preview envs first, behind the readiness checklist below

The rest of this post substantiates every row: what shipped, the HTTP gap, the head-to-head, the idle-bill math worked twice, and the beta checklist.

What 1.37 actually shipped

The September 2, 2026 Kubernetes blog post by Johannes Würbach states the feature plainly: an HPA using a suitable object metric or external metric can scale a workload to zero replicas and back, and the HPAScaleToZero feature gate is now enabled by default on both kube-apiserver and kube-controller-manager. Before v1.37 you needed an add-on, an external component, or the alpha gate switched on — which most managed control planes never exposed. The beta history is long: first alpha in v1.16, the ScaledToZero status condition and pause-disambiguation logic in v1.36, and default-on in v1.37 after integration and end-to-end coverage for zero-to-one from an external metric.

The mechanism has one core insight worth internalizing. CPU and memory metrics come from running pods, so at zero replicas there is no signal left to scale back up on. Object and external metrics — queue length, pending tasks, a Prometheus query — exist independently of the workers that consume them, so the HPA keeps reading them at zero. That is why the API server rejects minReplicas: 0 on an HPA that only has resource metrics: CPU/memory plus zero is a workload that could never wake up.

The canonical shape, straight from the upstream post, is a queue consumer driven by a Prometheus metric exposed through the Prometheus Adapter's External Metrics API:

yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: queue-worker
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: queue-worker
  minReplicas: 0
  maxReplicas: 10
  metrics:
    - type: External
      external:
        metric:
          name: queue_consumer_lag
          selector:
            matchLabels:
              name: worker_tasks
        target:
          type: Value
          value: "30"

One replica per 30 queued tasks, zero when the queue is empty, capped at ten. Three operational details from the same post matter more than the YAML.

First, start the Deployment with at least one replica: manually scaling a workload to zero has always meant "paused," and the HPA preserves that — it will not wake a workload it did not itself scale down. The ScaledToZero condition is how the controller tells the two states apart (True means the controller owns the zero; inspect it with kubectl describe hpa).

Second, the default five-minute downscale stabilization window still applies, so a momentary dip in queue length doesn't nuke every worker — tune it via spec.behavior.scaleDown if your workload needs different manners. Third, if the metrics adapter goes dark, the HPA reports ScalingActive=False (FailedGetExternalMetric) and the workload sits at zero: your metrics pipeline is now on the critical path for availability, not just observability. Verify the metric is readable (kubectl get --raw against the external metrics API) before you trust the autoscaler with it.

The one thing it cannot do: wake on HTTP

Here is the sentence from the upstream post that decides the whole HTTP story: "Kubernetes Services do not buffer requests while no Pods are ready, so HTTP and other request-driven workloads need a separate buffering layer." Native HPA wakes on metric changes, not on packets. When the first HTTP request arrives at a zero-replica Service, there is no pod to receive it and nothing holding the connection while one starts — the request fails instead of waiting.

Contrast that with the systems built for this job. The KEDA HTTP add-on puts an interceptor in the request path that buffers the first request, cold-starts the pod from zero to one, then forwards — the client sees a slow first response, not an error. Knative does the same through its activator. That buffering proxy is load-bearing infrastructure: without it, "scale to zero" for request-driven apps is "scale to connection-refused."

The wake path also sets expectations for latency. Native HPA cold start is a chain — the metric must be scraped, the HPA must reconcile, the scheduler must place a pod, the image must be present, the app must boot — which is why upstream scopes the feature to work that can wait in a durable queue.

Community measurements put the spread vividly: one lab measured ~3 seconds to serve from a CRIU snapshot restore versus ~115 seconds for a full Knative cold boot of a heavy image, a ~33x gap determined almost entirely by image and model-weight loading, not the autoscaler. The autoscaler decides whether you wake; the image decides how long it takes. Keep model weights and fat images warm in a node cache if wake latency is part of your tier promise.

Native HPA vs KEDA vs the HTTP layer, head to head

For the operator deciding what runs on the cluster, the comparison that matters is controllers and responsibilities, not logos:

Native HPA (1.37+)KEDA ScaledObjectKEDA HTTP add-onKnative Serving
Wakes on queue depth / external metricYesYesYes (via HTTP metrics)Via KPA/autoscaler config
Wakes on an incoming HTTP requestNo — no bufferingNo (core KEDA isn't in the HTTP path)Yes — interceptor buffers and forwardsYes — activator holds requests
Extra components to runNone (built into kube-controller-manager)KEDA operator + metrics pipelineKEDA + interceptor + routingKnative control plane + activator + networking layer
Trigger vocabularyObject/external metrics only60+ scalers (Kafka, SQS, Redis, cron, Prometheus, …)HTTP concurrency/RPSHTTP + concurrency
Job-shaped work (ScaledJob: one Job per batch)NoYesNoNo

Two rows deserve commentary. First, KEDA doesn't go away — it was never just "HPA that reaches zero." Under the hood KEDA generates and manages HPA objects itself; its value is the trigger vocabulary and the ScaledJob shape, neither of which native HPA replicates. Cron-triggered workers, queue consumers on exotic brokers, per-batch Jobs: still KEDA's. Note the long-standing boundary that survives 1.37 unchanged: event-driven scalers reach zero, while CPU/memory-based scaling keeps a floor — size your expectations (and your metrics) accordingly.

Second, what you actually delete from the queue-worker path is operational surface: one fewer operator to upgrade, no ScaledObject CRDs to manage for plain metric-driven workloads, and HPA semantics every Kubernetes operator already knows instead of a second autoscaling dialect. On owned hardware that saving is simplicity and upgrade-risk, not dollars — a KEDA operator's own footprint is small. Say it plainly in the ADR: the win is one fewer moving part per workload, not a smaller node bill.

So the migration rule is a sentence: move metric-driven queue and batch workers to native minReplicas: 0; keep KEDA where you use its triggers or ScaledJob; and budget a buffering layer for anything that wakes on HTTP, because neither native HPA nor core KEDA puts one in the request path.

The idle-app bill, worked twice

On owned hardware there is no per-pod meter, so "what does scale-to-zero save" needs restating: it frees reserved CPU and RAM for bin-packing, which collapses to fewer nodes or headroom for paying workloads. All numbers below are illustrative with stated assumptions — the method is the point, and you should rerun it with your own request sizes.

Case A: 20 queue workers (the full native win). Assume each worker requests 0.5 CPU and 512 MB — 10 CPUs and 10 GB reserved while idle. On small cloud boxes (2 vCPU / 4 GB class, a few euros a month each), that's roughly three nodes' worth of idle reservation. Native HPA at minReplicas: 0 returns essentially all of it whenever queues drain: the same three nodes now host your paying tenants, and the workers cost nothing but the metrics pipeline you were already running for observability. KEDA's removal from this path saves the operator upgrade-tax, not nodes — the node saving comes from zero itself, and native HPA gets you there with no extra controller.

Case B: 50 HTTP preview apps (the honest accounting). Assume each preview requests 0.25 CPU and 256 MB — 12.5 CPUs and ~13 GB idle, roughly four small nodes. Native HPA alone cannot sleep these (the HTTP gap above), so the real comparison is native HPA plus a buffering layer versus the managed alternative.

On the managed side the anchors are public: Render's free tier sleeps after ~15 minutes idle with a 30–60 second cold start and 750 instance-hours a month, while always-on starts around $7 per service per month — 50 always-on previews is $350/month before databases. Railway's Hobby tier ($5/month including usage credit, trial credit only for new signups, no permanent free tier) prices the same shape usage-based.

Against that, the self-hosted sleeping tier costs the buffering layer's footprint (an interceptor deployment plus the metrics pipeline — well under one small node) plus whatever fraction of wake-compute you actually use, with cold starts in the same tens-of-seconds band as Render's free wake once images are cached on the nodes. The saving is real — idle previews collapse from ~4 nodes to ~1 shared layer — but only because you counted the buffering layer instead of pretending native HPA wakes HTTP.

The sensitivity worth naming: the bill moves with request sizes and wake frequency, not with the autoscaler's brand. Halve the per-app requests and Case B fits on two nodes awake, one layer asleep. Wake previews constantly (CI hitting every branch) and nothing sleeps long enough to matter — the stabilization window and scrape intervals will keep replicas up, and you should price that tier as always-on instead.

Beta-readiness checklist for a tenant sleep/wake SLA

Beta plus on-by-default is not GA, and the upstream post is explicit that graduation waits on operational feedback. Don't back a tenant-facing "your app sleeps and wakes" SLA on it yet; do roll it to internal preview environments now, behind this checklist:

  1. Metrics pipeline first. The HPA can't wake what it can't read. Alert on ScalingActive=False / FailedGetExternalMetric as paging, not informational — at zero replicas a dead adapter is downtime.
  2. Upgrade order across skew. Both kube-apiserver and kube-controller-manager must support and enable the gate before any minReplicas: 0 HPA exists. A controller without the feature treats replicas: 0 as a manual pause and leaves the workload asleep. If you're on Cluster API, sequence the management upgrade before workload clusters take the new HPA shape.
  3. Teach the team the pause rule. Zero means two things now. Anyone debugging "my app won't start" must check the ScaledToZero condition before touching replicas — manually scaling to 1 a workload the HPA owns just confuses the next reconcile.
  4. Tune the stabilization window per workload. The 5-minute default downscale window is sane for queues, sluggish for chatty preview apps, and dangerous to shorten blindly (flapping replicas on a spiky metric). Set spec.behavior.scaleDown deliberately and watch replica-churn metrics after.
  5. Know the rollback. Before disabling the gate or downgrading: set affected HPAs back to minReplicas: 1 and scale zero-replica workloads up to at least one first. Downgrading with workloads at zero strands them there.
  6. Load-test the wake storm. Monday-morning "every preview wakes at 9am" is a thundering herd against the scheduler and image pulls — pre-warm node image caches and confirm the control plane's reconcile budget at your tenant count (this is the same controller-cache sizing work you'd do for any fleet growth).

What to do Monday morning

If you operate a git-push platform on your own Kubernetes: migrate one queue-worker fleet to native minReplicas: 0 with an external metric this week and delete the corresponding ScaledObjects — that's the safe, complete win. Keep KEDA installed for triggers and ScaledJob. For the HTTP sleeping tier, pilot native HPA with your chosen buffering layer on internal previews and measure wake latency with warm image caches before promising tenants anything. And file the beta feedback upstream with SIG Autoscaling — that's literally what the beta is for.

Bex.co is the open-source, AI-native Render alternative — push a git repo, get a running HTTPS service on machines you own. Star the repo on GitHub or deploy your first app today.

Related articles

Run this on infrastructure you own

bex is the open-source, AI-native Render alternative — push a git repo and get a running HTTPS service on your own machines.

Get started with bex