Autoscaling a .NET API sounds simple:
Traffic increases
↓
More CPU
↓
More Pods
But CPU is not always the best signal for application demand.
Consider an API that accepts requests and places work onto a message queue:
Client
↓
ASP.NET Core API
↓
Message Queue
↓
Background Worker
↓
Database
The API may have relatively low CPU utilization while the queue is growing rapidly.
A CPU-based Horizontal Pod Autoscaler (HPA) may therefore have little reason to add replicas.
This is where event-driven autoscaling becomes useful.
KEDA, or Kubernetes Event-driven Autoscaling, integrates external event sources with Kubernetes autoscaling. KEDA can monitor event sources and feed metrics to the Kubernetes HPA; it also handles activation and deactivation around zero replicas.
This article compares a native Kubernetes HPA approach with KEDA for an event-driven .NET workload and develops a benchmark methodology based on:
Scaling latency
Queue backlog
Request latency
Throughput
Replica count
CPU and memory
Scale-down behavior
Resource consumption
The goal is not to claim that KEDA is universally faster.
The goal is to determine which scaling signal produces better behavior for a particular workload.
HPA and KEDA Solve Different Problems
Kubernetes HPA automatically adjusts the replica count of a scalable workload according to configured metrics. The autoscaling/v2 API supports resource, custom, and external metrics.
A traditional HPA might look like:
CPU utilization
↓
HPA
↓
Deployment replicas
KEDA introduces an event-aware layer:
Queue / Event Source
↓
KEDA
↓
HPA
↓
Deployment replicas
KEDA's current architecture uses its operator for 0-to-1 and 1-to-0 activation/deactivation, while the Kubernetes HPA handles 1-to-N scaling based on metrics exposed by KEDA.
Therefore, a fair comparison is:
| Approach | Primary Scaling Signal | Scale to Zero | Best Fit |
|---|---|---|---|
| HPA | CPU, memory, custom/external metrics | Possible in specific configurations | Resource-driven workloads |
| KEDA | Event-source metrics | Yes, for supported ScaledObject scenarios | Event-driven workloads |
| HPA + custom metrics | Application/event metric | Depends on configuration | Custom application demand |
| KEDA + HPA | Event metric exposed through KEDA | Yes | Queue/event workloads |
The Benchmark Workload
Use a simple .NET architecture:
┌──────────────┐
│ Load Generator│
└──────┬───────┘
↓
┌──────────────┐
│ ASP.NET Core │
│ API │
└──────┬───────┘
↓
┌──────────────┐
│ Message Queue│
└──────┬───────┘
↓
┌──────────────┐
│ .NET Worker │
└──────┬───────┘
↓
┌──────────────┐
│ PostgreSQL │
└──────────────┘
For the benchmark, the worker is the important component.
The worker consumes messages from the queue and performs a controlled amount of processing.
This makes queue depth a meaningful scaling signal.
Why CPU Alone Can Be Misleading
Suppose the queue contains:
10,000 messages
but the worker is waiting on I/O.
CPU might remain relatively low:
CPU = 25%
Queue = 10,000
An HPA configured for:
CPU target = 70%
may not scale.
KEDA can instead use the queue depth as the scaling signal.
Conceptually:
Queue:
10,000 messages
↓
KEDA
↓
Desired replicas increase
↓
More workers
↓
Queue drains faster
This is the central hypothesis of the benchmark.
Build the .NET Worker
A simplified worker could use the .NET BackgroundService abstraction:
public sealed class OrderWorker(
IMessageConsumer consumer,
IOrderProcessor processor)
: BackgroundService
{
protected override async Task ExecuteAsync(
CancellationToken stoppingToken)
{
await foreach (
var message in consumer.ReadAsync(
stoppingToken))
{
await processor.ProcessAsync(
message,
stoppingToken);
}
}
}
The production implementation depends on the selected queue technology.
The benchmark should keep the worker implementation identical between HPA and KEDA experiments.
Only the scaling mechanism should change.
Add Controlled Processing Time
For a reproducible experiment, the worker can perform deterministic work.
For example:
public sealed class OrderProcessor
{
public async Task ProcessAsync(
OrderMessage message,
CancellationToken cancellationToken)
{
await Task.Delay(
TimeSpan.FromMilliseconds(100),
cancellationToken);
// Perform deterministic test work.
}
}
This is a benchmark mechanism, not a recommendation for production processing.
The purpose is to create a controllable service time.
Define the HPA Configuration
A resource-based HPA can target CPU utilization:
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: order-worker
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: order-worker
minReplicas: 1
maxReplicas: 20
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
The autoscaling/v2 API supports configurable scaling behavior and multiple metrics. Kubernetes calculates the desired replica count from the relationship between the observed metric and configured target.
Resource Requests Matter
CPU utilization-based HPA depends on CPU resource requests.
For example:
resources:
requests:
cpu: "250m"
memory: "256Mi"
limits:
cpu: "1"
memory: "512Mi"
If resource requests are missing, CPU utilization may not be defined for the relevant HPA calculation. Kubernetes explicitly documents this behavior.
Therefore, resource requests must be part of the benchmark configuration.
Define the KEDA ScaledObject
KEDA uses a ScaledObject to connect a workload to an event source.
A simplified structure is:
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: order-worker
spec:
scaleTargetRef:
name: order-worker
minReplicaCount: 0
maxReplicaCount: 20
pollingInterval: 5
triggers:
- type: rabbitmq
metadata:
queueName: orders
mode: QueueLength
value: "10"
The exact trigger metadata depends on the event source and KEDA scaler being used.
KEDA's ScaledObject defines the target workload, replica bounds, polling behavior, and triggers. The current KEDA documentation describes pollingInterval as the interval used to check trigger sources, with a default of 30 seconds.
For benchmarking, explicitly configure the interval instead of relying on an implicit default.
KEDA's Two Scaling Phases
Understanding KEDA's scaling model is essential.
There are effectively two phases:
0 ↔ 1 replicas
↓
KEDA activation
1 ↔ N replicas
↓
Kubernetes HPA
KEDA handles activation around zero replicas, while HPA handles scaling after the workload is active.
This makes KEDA particularly interesting for workloads that can remain idle.
Benchmark Question #1: How Fast Does Scaling Begin?
The first metric should be:
Scale-up latency
Define:
T0 = load spike begins
T1 = scaling signal becomes active
T2 = new Pod starts
T3 = new Pod becomes Ready
T4 = new Pod processes useful work
Then measure:
T4 - T0
This is more useful than measuring only:
HPA updated replicas
because a desired replica count does not mean the new Pod is ready to process requests.
Benchmark Question #2: How Fast Does the Queue Drain?
Record:
Initial queue depth
Peak queue depth
Queue depth over time
Time to return below threshold
For example:
Queue Depth
│
│ █
│ █ █
│ █ █
│ █ █
│____█_______█________
Time
The important measurement is not simply the maximum number of replicas.
It is how effectively the system responds to backlog.
Benchmark Question #3: Does CPU HPA React Too Late?
Consider:
Queue:
0 → 10,000
CPU:
30% → 40%
A CPU-based HPA may not scale aggressively.
KEDA sees:
Queue:
10,000
and can use that event metric directly.
The experiment should determine whether this translates into:
Lower queue latency
+
Faster backlog recovery
without causing excessive resource usage.
Benchmark Question #4: Does KEDA Over-Scale?
Event metrics can also create aggressive scaling.
Suppose:
Queue = 10,000
and:
Target = 10 messages/replica
The autoscaler can request a large number of replicas, bounded by the configured maximum.
KEDA's documentation explains that trigger metrics ultimately feed HPA calculations, with AverageValue and Value metric types producing different replica calculations.
Therefore, measure:
Desired replicas
Actual replicas
Queue depth
Processing throughput
together.
Benchmark Question #5: What Happens When the Load Stops?
Scale-down behavior is equally important.
Create:
Load spike
↓
Queue grows
↓
Workers scale out
↓
Load stops
↓
Queue drains
↓
Workers scale down
Measure:
Last message processed
↓
Replica reduction
↓
Minimum replica count
Kubernetes HPA has configurable scale-down behavior and stabilization windows. The documented default scale-down stabilization window is 300 seconds when no custom behavior is specified.
KEDA can pass HPA behavior configuration through its ScaledObject.
Benchmark Question #6: Does Scale-to-Zero Save Resources?
For an intermittent workload, compare:
HPA:
minReplicas = 1
with:
KEDA:
minReplicaCount = 0
Measure:
Idle CPU
Idle memory
Replica-hours
Startup latency
First-message latency
Do not claim a universal cost saving.
The actual result depends on:
Pod resource requests
Cluster capacity
Workload frequency
Cloud pricing
Startup time
Minimum replicas
Benchmark the Cold Start
Scale-to-zero introduces a trade-off.
Before the event:
Replicas = 0
A new event arrives:
Event
↓
KEDA detects activity
↓
Deployment scales
↓
Pod starts
↓
Readiness passes
↓
Work begins
The first event can therefore experience additional latency.
Measure:
Event arrival
↓
First Pod ready
↓
First successful processing
This is one of the most important metrics in the KEDA benchmark.
Benchmark Warm Scaling
Then repeat the experiment with:
minReplicaCount = 1
Now there is already a running worker.
Compare:
Cold:
0 → 1
Warm:
1 → N
This separates scale-to-zero startup cost from ordinary horizontal scaling.
Test Different Queue Depths
Do not test one backlog size.
Use a matrix such as:
100 messages
1,000 messages
10,000 messages
100,000 messages
The exact values should be chosen based on the capacity of the test environment.
For each run, measure:
Scale-up time
Peak replicas
Drain time
P95 processing latency
CPU
Memory
Test Different Arrival Rates
Queue depth is not the only variable.
Test:
10 messages/sec
100 messages/sec
500 messages/sec
1,000 messages/sec
The appropriate rates depend on the workload.
The important relationship is:
Arrival rate
vs
Processing rate
If:
Arrival rate > Processing rate
the queue will continue growing unless scaling increases capacity.
Calculate Required Processing Capacity
Suppose one worker processes:
10 messages/sec
and incoming traffic is:
100 messages/sec
The theoretical steady-state requirement is:
100 / 10 = 10 workers
This is not a production capacity guarantee.
It is simply a baseline for the experiment.
Real systems also need to account for:
Startup time
Failures
Retries
I/O latency
Uneven work
Queue semantics
Database capacity
Benchmark With Database Pressure
A worker often does more than consume messages.
It may execute:
Read
Transform
Write
against a database.
Therefore, add PostgreSQL or another production-relevant datastore to the benchmark.
Measure:
Worker replicas
+
Database CPU
+
Database connections
+
Query latency
An autoscaler can improve queue throughput while simultaneously overwhelming the database.
That is an autoscaling failure, not a success.
The Database Connection Trap
Suppose each .NET worker creates:
10 database connections
and autoscaling produces:
20 workers
The database may suddenly receive:
200 connections
Scaling the application does not automatically mean the database can scale at the same rate.
Therefore, include database connection pressure in the benchmark.
Benchmark Backpressure
A production system needs a maximum sustainable rate.
Consider:
Queue
↓
Worker
↓
Database
If the database becomes saturated:
Database latency ↑
↓
Worker throughput ↓
↓
Queue depth ↑
↓
Autoscaler adds workers
↓
Database pressure ↑
This is a positive feedback loop.
More replicas can make the problem worse.
Therefore, the benchmark should test the system near and beyond its sustainable capacity.
Benchmark Maximum Replica Limits
Configure:
maxReplicaCount: 20
Then create a load that requires more than 20 replicas.
Observe:
Queue continues growing
+
Replicas remain capped
This verifies that the safety boundary works.
A maximum replica count is not merely a cost-control setting.
It can protect downstream systems.
Benchmark HPA Behavior
Kubernetes HPA operates as a control loop. The default controller synchronization period is 15 seconds, although this is configurable.
Therefore, do not interpret autoscaling as instantaneous.
A useful timeline is:
Load changes
↓
Metric changes
↓
Metric becomes observable
↓
HPA evaluates
↓
Desired replicas change
↓
Scheduler places Pods
↓
Container starts
↓
Readiness passes
↓
Traffic/work distribution changes
Every stage contributes latency.
Benchmark KEDA Polling
KEDA's default pollingInterval is 30 seconds, but it can be configured per ScaledObject.
For example:
spec:
pollingInterval: 5
This does not mean the complete scaling operation will always finish within five seconds.
It means KEDA checks the trigger according to that polling configuration.
The rest of the control path still matters.
Do Not Set Polling to an Arbitrarily Low Value
Lower polling intervals can increase responsiveness, but they can also increase load on the event source and KEDA infrastructure.
Test:
5 seconds
10 seconds
30 seconds
and measure:
Scale latency
Event-source query rate
KEDA resource consumption
Choose the lowest interval that provides meaningful operational value.
Benchmark the HPA Metrics Path
For CPU-based HPA, verify that metrics are actually available.
Run:
kubectl top pods
Then inspect:
kubectl get hpa
For more detail:
kubectl describe hpa order-worker
Look for:
Current Metrics
Desired Replicas
Current Replicas
Conditions
Events
Kubernetes documents that HPA commonly obtains resource metrics through the Metrics Server and can also consume custom and external metrics through the corresponding APIs.
Inspect KEDA Metrics
KEDA exposes external metrics through its metrics API.
For example:
kubectl get --raw \
"/apis/external.metrics.k8s.io/v1beta1/namespaces/default/..."
Join the conversation! Your thoughts help the community grow.