An async worker can be failing its users while every CPU graph in the dashboard looks healthy. The worker is waiting on a slow API, processing one large video at a time, or simply too few jobs are arriving to consume a core. Meanwhile, the queue grows from 20 messages to 2,000.
That is the blind spot in a CPU-first autoscaling contract. A web service can often use request rate or CPU as a useful proxy for demand. A queue-backed worker already has a demand signal sitting in front of it: backlog, lag, age, or processing time. KEDA turns that signal into replica decisions inside Kubernetes.
Here is the concrete difference. Assume one worker can process 12 jobs per minute, each job takes about five seconds of worker time, and the operator wants no more than 20 waiting or in-flight jobs per replica. The queue-driven target produces this result:
| Queue state | Desired workers before limits | Result with maxReplicaCount: 20 |
|---|---|---|
| 0 jobs | 0 | 0, after the activation/cooldown rules |
| 1–20 jobs | 1 | 1 |
| 21–60 jobs | 2–3 | 2–3 |
| 120 jobs | 6 | 6 |
| 400 jobs | 20 | 20, capped |
| 800 jobs | 40 | 20, capped; alert on unmet capacity |
The usual approximation is ceil(backlog / target-per-replica), bounded by the minimum and maximum replica counts. At 120 jobs, six replicas have a nominal capacity of 72 jobs per minute. If arrivals are 60 jobs per minute, the backlog drains at roughly 12 jobs per minute after the new pods are ready. That is an operating decision a tenant can inspect and test; “scale when CPU reaches 70%” does not encode it.
This is what a git-push PaaS gives up when its only autoscaling controls are resource utilization or a replica slider: the ability to express the workload’s unit of demand. Render and Railway make deployment straightforward, but their documented tenant-facing scaling surfaces do not expose queue depth as a first-class trigger.
CPU can be green while the queue is red
Consider a typical application split into two services:
- An HTTP service accepts an upload and places a job on Redis, SQS, RabbitMQ, or Kafka.
- A background worker consumes the job, calls a third-party API, writes a result, and acknowledges the message.
The HTTP service has a natural request signal. The worker has a queue signal. They are related, but they are not interchangeable.
A worker that spends most of its time blocked on an external API may use 8% CPU while its queue has a five-minute backlog. A worker running one memory-heavy job at a time may hit its memory limit without a large message count. A burst of short jobs may create high CPU for 30 seconds and disappear before a slow scaler reacts. The right metric depends on the bottleneck:
| Workload shape | Signal that describes demand | What CPU/memory alone can miss |
|---|---|---|
| Image or video jobs | Queue length and oldest-job age | A blocked worker can look idle |
| Kafka consumer | Consumer-group lag, bounded by partitions | CPU does not show messages waiting in partitions |
| SQS or RabbitMQ worker | Visible plus in-flight messages | Acknowledgment and retry behavior alter CPU independently |
| Scheduled or bursty tasks | Event count or activation threshold | Average utilization can hide short spikes |
| API fan-out worker | Queue length plus downstream latency | Adding workers may worsen a rate-limited dependency |
The second column is not automatically a license to add pods. It is a better starting point for a control loop because it measures work that has not yet been completed. A production policy still needs a maximum, a cooldown, downstream rate limits, and an alert when the backlog cannot be cleared.
What KEDA adds to the Kubernetes autoscaling loop
KEDA is a CNCF Graduated project. Its current documentation lists 77 built-in scalers, including Kafka, AWS SQS, Redis Lists and Streams, RabbitMQ, Prometheus, databases, cloud queues, and scheduled triggers. KEDA 2.20 is the latest release listed in the project roadmap as of August 2026, following 2.19 in February and 2.20 in June.
The useful distinction is the division of responsibility:
- The
keda-operatorwatches aScaledObjectand decides whether an inactive workload should go from zero to one replica. - KEDA’s metrics API server exposes the external metric to Kubernetes.
- The standard Kubernetes HorizontalPodAutoscaler handles the one-to-many decision using the metric target.
That last step matters. KEDA is not a replacement scheduler and it does not invent a new replica algorithm. Kubernetes calculates a desired count from the ratio of current metric to target metric, multiplied by the current replica count. With an external queue metric, KEDA supplies the current queue value and the target-per-replica value.
For a Redis list, a minimal worker policy can look like this:
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: thumbnail-worker
namespace: media
spec:
scaleTargetRef:
name: thumbnail-worker
minReplicaCount: 0
maxReplicaCount: 20
pollingInterval: 30
cooldownPeriod: 300
triggers:
- type: redis
metadata:
addressFromEnv: REDIS_ADDRESS
listName: thumbnails
listLength: "20"
authenticationRef:
name: thumbnail-redis-authThe listLength value is the average target for scaling actions. With 120 items, the HPA has a target that corresponds to six replicas. With no items, KEDA can deactivate the worker, wait through the configured cooldown, and scale it to zero. When an item arrives, the operator can activate the workload even though there are no running worker pods whose CPU metrics could be read.
KEDA separates activation from scaling: zero-to-one is handled by the operator; one-to-N is handled through the HPA. Pin and measure these values for the installed version: the ScaledObject reference documents a 30-second polling interval and a 300-second scale-to-zero cooldown. Cooldown guards against tearing down a worker immediately after the last active event; it is not a promise that a job waits five minutes.
The same shape works for Kafka lag or SQS. The SQS scaler, for example, can count visible and in-flight messages by default and lets an operator set a queue length such as five messages per pod. Kafka has another hard boundary: a consumer group cannot usefully consume a topic with more active consumers than available partitions. maxReplicaCount must respect that topology or the extra pods will simply sit idle.
What Render and Railway expose instead
The comparison should be about documented controls, not assumptions about private implementation. Render and Railway both let teams deploy multiple copies of a service. The gap is the metric a tenant can configure.
| Capability in the documented product surface | Render | Railway | KEDA on Kubernetes |
|---|---|---|---|
| Scale from a git-deployed service | Yes | Yes | A platform must provide the deployment path |
| Horizontal replica control | Manual or autoscaling | Manual replica count; staged changes | HPA-managed desired count |
| Built-in trigger documented for CPU/memory | CPU and/or memory targets | Vertical resource limits; service replicas | CPU/memory are available through Kubernetes |
| Queue, lag, or external metric as a tenant trigger | Not exposed in the scaling guide | Not exposed in the scaling guide | Built-in scalers and Prometheus/custom metrics |
| Scale to zero from an external event | Not described as part of service autoscaling | Not described as part of service scaling | Supported by KEDA event activation |
| Upper bound | Up to 100 service instances | Up to 50 total replicas in the CLI reference; plan limits also apply | maxReplicaCount in each ScaledObject |
| Scale timing | Up immediately; down after a few minutes | Replica changes are applied as a staged service change | Polling, HPA sync, cooldown, and behavior policies |
Render’s autoscaling guide says it averages CPU and/or memory utilization across instances, calculates a new count, scales up immediately, and waits a few minutes before scaling down. Autoscaling requires a Pro workspace or higher, and Render says a service can reach 100 instances. That is a sensible contract for request-serving processes where resource utilization is a usable proxy for demand.
It remains a resource contract. The guide does not let a tenant say “add one worker for every 20 Redis jobs” or “scale when Kafka lag exceeds 100 messages per partition.” Render’s background-worker documentation explicitly describes workers polling a queue, and its Temporal example says a team could use a task-latency signal to scale programmatically through the Render API. That workaround is useful, but it moves the event loop, credentials, rate limiting, and failure handling into application-specific code.
Railway’s scaling documentation describes vertical autoscaling up to a service’s vCPU and memory limits. Horizontal scaling is exposed as a configurable number of replicas, with optional placement across regions; the CLI reference supports setting regional replica counts and caps the total at 50. Its documented model is clear and useful for a known replica count, but it does not present a KEDA-like queue trigger, external metrics adapter, or per-service scale-to-zero event policy.
“Black box” therefore means black box at the tenant control surface. A provider may have internal automation that is not documented or configurable. The practical question for a team is narrower: can the deployment declaration express the metric that controls worker capacity, and can the team inspect the resulting decision?
The missing feature is an API contract, not a YAML checkbox
Adding KEDA to a cluster is the easy part. A PaaS has to turn a powerful Kubernetes primitive into a safe tenant feature.
First, the platform needs a workload model. A web service might default to request-oriented scaling, while a worker needs a queue reference, target backlog, minimum and maximum replicas, activation threshold, and cooldown. The platform should reject a queue target with missing authentication rather than allowing a scaler to fail silently.
Second, credentials need an ownership boundary. A tenant worker may read a queue, but the platform’s control plane should not casually expose the queue password in a generated HPA or dashboard. KEDA’s TriggerAuthentication and ClusterTriggerAuthentication objects are useful building blocks, but a multi-tenant PaaS still has to scope secrets, namespaces, RBAC, and cross-namespace references.
Third, the metric needs semantics. “Queue length” can mean visible messages only, visible plus in-flight work, stream lag, or an estimate from a Prometheus query. Retries can make a queue appear healthy while a poison message cycles. A queue that contains 20 ten-second jobs is not equivalent to one containing 20 two-hour jobs. For long-running work, expose age or processing-time metrics alongside count and document what acknowledgment means.
Fourth, the platform has to show why a change happened. A useful tenant event says: “scaled from 3 to 6 because thumbnails reached 120, target 20, last sampled 30 seconds ago; capped at 6 by the configured max.” Without that trail, event-driven scaling becomes another opaque graph with surprising bills.
Finally, KEDA only changes workload replicas. It does not create a machine when the cluster has no allocatable CPU or memory. A self-hosted fleet needs node provisioning, node replacement, quotas, and a policy for what happens when the requested six replicas cannot be scheduled. Cluster API can manage the lifecycle of the machines and clusters underneath; a node autoscaler or capacity controller still has to connect pending pods to available capacity.
This is where the distinction between app autoscaling and fleet lifecycle matters. KEDA can notice that a queue needs 20 workers. It cannot decide whether to add a new bare-metal node, wait for a replacement, move another tenant, or refuse the deployment because the operator’s capacity budget is exhausted. A platform that promises queue-aware scaling must expose both sides of that boundary.
A practical contract for a git-push PaaS
A platform does not need to expose all 77 scalers on day one. A defensible first contract can support one queue backend and one generic Prometheus path:
- Let the app declare
metric: queue,targetPerReplica,minReplicas,maxReplicas,activationThreshold, andcooldownSeconds. - Translate that declaration into a namespaced KEDA object with a platform-owned authentication reference.
- Keep a conservative maximum and enforce tenant quotas before scheduling.
- Surface sampled value, target, desired replicas, actual replicas, pending pods, and last scaler error in the service events.
- Test with a real backlog and a slow downstream dependency, not only a CPU stress test.
For the worked example, load-test three arrival rates: 12 jobs per minute, 60 jobs per minute, and 120 jobs per minute. With one worker processing 12 jobs per minute and a target of 20 messages per replica, six replicas can outpace the 60-job rate but not the 120-job rate. At the highest rate, 20 replicas provide 240 jobs per minute of nominal capacity, but only after startup and only if the downstream API permits that concurrency. The right action may be to raise the target, add rate limiting, or increase the maximum; the queue metric makes the trade visible instead of pretending CPU is the whole system.
This is a meaningful open-platform differentiator: workers scale on work remaining rather than on a proxy metric chosen by the hosting provider. Operators still retain responsibility for cluster capacity, secret isolation, and failure policy.
For a self-hosted platform such as Bex.co, that is a natural extension point: a deployment can remain simple for the common web-service case while exposing queue-aware policies for teams that run workers, agents, media pipelines, or scheduled jobs on machines they own. The platform’s job is to make the policy legible and safe; Kubernetes and KEDA provide the control loop underneath.
The broader lesson is that autoscaling is a choice of what the platform considers demand. CPU and memory are useful defaults; queue depth, lag, age, and event rate are closer to the work a background service owes its users. A git-push PaaS that exposes both lets each workload state its real bottleneck. One that exposes only a replica slider makes every worker team build the missing autoscaler beside the application.
Sources
- KEDA scalers documentation
- KEDA scaling deployments and activation behavior
- KEDA ScaledObject specification
- KEDA Redis Lists scaler
- KEDA AWS SQS scaler
- KEDA CNCF graduation announcement
- KEDA roadmap and release schedule
- Kubernetes HorizontalPodAutoscaler algorithm
- Render service scaling
- Render background workers
- Railway scaling
- Railway CLI scale reference
Bex.co is the open-source, AI-native Render alternative: push a git repository, get a running HTTPS service on machines you own. Explore the project on GitHub.



