Free cookie consent management tool by TermsFeed Generator

The Delay a Window Demands

Argument so far

A queue consumer is about the most natural thing to autoscale. Its work arrives as messages, its backlog is a number the broker already reports, and the obvious rule writes itself: one pod for every so many waiting messages. KEDA ships that rule for Kafka, for SQS, for RabbitMQ and for dozens of other sources. AWS documents the same idea for its own fleets as backlog per instance. It is one of the most common ways to scale asynchronous work.

Take such a consumer in a model. Each pod handles ten messages a second. Demand steps up to two thousand messages a second, which needs two hundred pods, and then stays there, perfectly flat, for twenty hours. The autoscaler aims for ten waiting messages per pod, the default threshold of KEDA’s Kafka scaler. A new pod takes 90 seconds to start consuming, and the Kubernetes autoscaler applies its default five-minute window to every scale-down.

Over the last hour of the run, with demand that never moves, the pod count does not settle. It spends most of the hour pinned at the configured ceiling of two thousand, ten times what the work needs. Then it falls to a handful of pods, as low as one, and climbs back. The fifth percentile of the hour is 8 pods, the ninety-fifth is 2,000, and the mean is 1,551.

The obvious suspect is the five-minute window, so remove it. The loop still swings from one pod to two thousand, with a mean of 936. Remove nothing, and instead raise the threshold until each pod’s share of the backlog is forty seconds of work. With the window, the loop still hunts, between 40 and 503 pods over the hour. Without it, the loop settles into a band from 186 to 234 pods.

So the window matters, but it is not the whole story, and removing it did not rescue the default. Something about a backlog-driven loop needs a standing quantity of waiting work to stay at rest, and the window raises how much. This post finds how much.

In brief
Notation used across the series

where:

The Belief: the Window Makes the Loop Stable

Bound The Kubernetes documentation presents the stabilization window as the remedy for an unstable replica count. It names the problem first: “it is possible that the number of replicas keeps fluctuating frequently due to the dynamic nature of the metrics evaluated. This is sometimes referred to as thrashing, or flapping”[1] . It then gives the fix: “The stabilization window is used to restrict the flapping of replica count when the metrics used for scaling keep fluctuating.” With a five-minute window, the page explains, the controller “avoids having the scaling algorithm frequently remove Pods only to trigger recreating an equivalent Pod just moments later.”

Bound The default applies the window to scale-downs only. The documentation states it directly: “For scaling down the stabilization window is 300 seconds”, while “For scaling up there is no stabilization window.” KEDA, which turns a queue into a metric the Kubernetes autoscaler can read, passes the setting through untouched. Its specification says “KEDA would feed values from this section directly to the HPA’s behavior field,” and the example it prints sets stabilizationWindowSeconds: 300[2] .

The belief is reasonable, and for the loop the documentation has in mind it is correct. A noisy metric makes a ratio controller add and remove pods in quick succession. Each removal throws away a warm pod that the next sync will ask for again. A window that holds the highest recent recommendation stops that. It costs a few minutes of extra pods after every peak and buys a calmer count.

The belief carries an assumption it never states. It treats the window as a filter on noise, something that removes fluctuation without changing the loop it sits in. In a loop driven by a backlog the window is part of the dynamics. It holds pods after the work that justified them is gone, and holding pods drains the backlog the next decision will read.

How a Queue Reaches the Controller

Bound KEDA does not scale anything itself between one pod and many. It publishes each trigger as an external metric and creates a HorizontalPodAutoscaler that reads it. By default the metric has the type AverageValue, and KEDA’s specification spells out what follows: “If our metric is the queue size, the threshold is 5 messages, and the current message count in the queue is 20, HPA will scale the deployment to 20 / 5 = 4 replicas, regardless of the current replica count.”[2]

Bound The controller’s source agrees. For an external metric with a per-pod target, GetExternalPerPodMetricReplicas computes math.Ceil(float64(usage) / float64(targetUsagePerPod)), after the same tolerance check as every other metric[3] . The recommendation is the backlog divided by the threshold, rounded up. The current count enters only through the tolerance: if the backlog per current pod is within 10% of the threshold, the controller holds.

Bound That rule differs from the CPU path of One Bit per Dead Time in one decisive way. The CPU path counted ordered pods at zero usage, so it could not order the same capacity twice within a round. The backlog path has no such accounting. A pod that has been ordered but is not yet consuming does nothing to the backlog. The backlog keeps growing during the dead time, and the recommendation grows with it. The controller keeps ordering for work that the pods already on their way will eventually take.

One Sync, Walked Through

Estimate The port runs the external per-pod calculation on concrete inputs, with a threshold of ten messages per pod. The replica count is every pod the deployment has, whether it is consuming yet or still on order. Sweep the backlog for three replica counts.

The ported external per-pod calculation swept over the backlog, for three current replica counts. The recommendation is the backlog divided by the threshold, whichever count is current, except inside each count's tolerance band. Pods on order do not change it: nothing in the signal says they exist.

Bound The three lines lie on top of one another almost everywhere. Two thousand messages at ten per pod is two hundred pods, whatever the current count, which is the rule KEDA describes. Each line leaves the shared diagonal only inside its own tolerance band. With two hundred replicas, any backlog from 1,800 to 2,200 messages holds the count at two hundred, and 2,300 messages asks for 230. The flat steps are the only place the current count matters. That has a consequence for the dynamics. Suppose a hundred of three hundred replicas are still starting. They have not touched the backlog, which has kept growing to four thousand messages, and the controller asks for four hundred. It has ordered a hundred more pods for work that the hundred already on their way will absorb when they arrive. The same line read downward is the other branch. A backlog drained to a thousand messages asks for a hundred pods, and only the window stands between that request and the count.

Bound The defaults set the scale of everything that follows. KEDA’s Kafka scaler takes lagThreshold, the “Target value for the total lag (sum of all partition lags)”, with a default of 10. Its SQS scaler takes queueLength, default 5, and its documentation gives the intended reading: “if one pod can handle 10 messages, set the queue length target to 10.” KEDA’s cooldownPeriod of 300 seconds “only applies when scaling to 0”. Between one replica and many, the Kubernetes autoscaler and its own window do the work.

Bound One more default bounds the Kafka case. Unless allowIdleConsumers is set, the Kafka scaler will not ask for more replicas than there are partitions, because a consumer beyond the partition count would sit idle. A Kafka consumer group therefore hunts, when it hunts, between one pod and its partition count. The model in this post has no partitions. Its ceiling is a replica limit of 2,000, which stands for whichever cap binds first in a real deployment.

Bound AWS documents a sizing rule for the same kind of loop. Its guide to scaling on an SQS queue defines backlog per instance and sets the target from the latency the application can accept: “take the acceptable latency value and divide it by the average time that an EC2 instance takes to process a message”[4] . With a ten-second acceptable latency and a tenth of a second per message, the target is a hundred messages per instance. This rule turns out to be the right one for choosing the threshold, for a reason the guide does not give. The next section shows why.

A Backlog Is an Integrator

Before the equations, one step worked through with numbers. Take a consumer of the size a test cluster can hold. Each pod handles ten messages a second, and demand steps from 50 to 200 messages a second, so the need goes from five pods to twenty. The dead time is 90 seconds, the window is the default five minutes, the replica ceiling is 100, and the threshold is KEDA’s default of ten messages per pod. At rest that threshold holds one second of work per pod.

Estimate In the first thirty seconds after the step the backlog reaches 4,700 messages, and the controller doubles its count at each of the two syncs, from five pods to twenty. By sixty seconds it has ordered 80 pods, and by ninety it sits at the ceiling of 100, five times the need, while only ten pods are consuming. The backlog keeps growing until the ordered pods start to consume, and at two minutes it still holds 14,850 messages. A hundred pods then clear it within a minute: 2,250 messages at 150 seconds and none at 180.

Estimate With the queue empty the recommendation falls to one pod, and the window holds the hundred pods for five more minutes anyway. When it lets go, the count drops at once to a single pod, and the backlog starts to build again from zero. The second climb begins from one pod instead of five, so it goes higher, past 24,000 messages, and from then on the loop repeats it exactly, every 510 seconds. Demand never moves after the step. The swing comes from three things the rest of this post takes apart: the backlog remembers every second of shortfall, the pods ordered against it arrive one dead time late, and the window keeps them one window too long on the way down.

The backlog changes at the rate work arrives minus the rate pods remove it. With demand , a per-pod rate c and N pods consuming:

The autoscaler sets N from L. The threshold T, in messages per pod, says how much backlog one pod should carry. At rest, with N pods exactly matching demand, the backlog sits at T messages per pod.

Definition 4 -- Standing Delay: the seconds of waiting a backlog-scaled loop holds at rest, which is the threshold per pod over the rate of one pod

Definition 4 (Standing Delay). A loop that scales on backlog with a threshold of T messages per pod, served by pods of rate c, holds at rest a backlog of T messages per pod. Its standing delay is

where:

By Little’s law, D is also the time every message waits in the queue at rest.

Bound At rest the backlog is , and Little’s law gives the wait as backlog over arrival rate[5] . That is , the standing delay. The threshold is a latency in disguise. The Kafka default of ten messages per pod, at ten messages a second per pod, is one second of waiting. The AWS rule, acceptable latency over time per message, sets the standing delay equal to the acceptable latency. The guide is choosing D directly.

Bound Strip the controller to its simplest form first. Suppose the pods consuming now are the ones the backlog asked for one dead time ago, . Write for the relative deviation of the backlog from rest. The backlog equation becomes

This is the linear delay equation with gain . The dimensionless product says how many standing delays fit in one dead time.

Proposition 3 -- Delay Equation Thresholds: the pure-delay backlog loop is stable iff the standing delay exceeds 2/pi dead times, and its characteristic equation has a real root iff the delay is at least e dead times

Proposition 3 (Delay Equation Thresholds). For with :

  1. The equilibrium is asymptotically stable if and only if , that is .
  2. The characteristic equation has a real root if and only if , that is .

Proof sketch. The characteristic equation is . For part 1, roots move continuously with a. As a tends to zero, one root tends to zero from the left and the rest have real parts tending to minus infinity. A root reaches the imaginary axis at only if and , which first happens at , so at . For part 2, a real root s satisfies , so s is negative. On the negative axis has its minimum at , so a real root exists exactly when .

Bound Part 1 of the proposition is the symmetric condition Anti-Fragile Decision-Making at the Edge used as a filter on actions, gain times delay below . That post applied it as a sufficient condition for a loop with one delay. For this equation it is also necessary. Part 2 is the stricter condition for a non-oscillating response, and it returns later in this post with a different job.

Estimate In the specimen’s terms, with a 90-second dead time, the pure-delay loop needs a standing delay above , about 57 seconds, to be stable at all. The Kafka default supplies one second. The pure-delay loop at the default is unstable by a factor of nearly sixty. That alone explains why the default hunts. It does not explain why the window makes things worse, or by how much, because the pure-delay model has no window in it.

Fit The reason a backlog loop needs a standing delay at all is the integrator. A backlog remembers every second in which capacity fell short and every second in which it ran over. A controller that reads the backlog through a dead time acts on a memory that is already out of date. The integrator keeps adding to the error while the correction is on its way. A larger threshold makes each pod’s share of the backlog larger, so the controller’s response to a given deviation is gentler. The standing delay is the price of that gentleness, paid by every message, all the time.

Fit The two loops in this series fail in opposite directions for one reason. The CPU loop of One Bit per Dead Time counted every pod on order at zero usage. It never ordered the same capacity twice, and it paid for that caution with a slow, fixed rate of learning when its metric saturated. The backlog loop learns the size of an overload at once, because its signal has no ceiling. It pays by ordering past the pods already on their way, since nothing in its signal says they exist. Both loops face the same dead time. One spends it waiting, and the other spends it accumulating a debt it will later overpay.

Two Branches, Two Delays

The deployed loop is not the pure-delay loop. Its way up and its way down pass through different mechanisms, and the difference is what the window adds.

Bound On the way up, a recommendation becomes capacity only after the dead time. A pod ordered now starts consuming 90 seconds later. If the recommendation dips in between, pods still starting are the first to go. The ReplicaSet controller ranks pods for deletion with unscheduled before scheduled, pending before running and not ready before ready[3] . So a pod reaches service only if it was wanted for the whole dead time without a break. The consuming count is the minimum of the recommendation over the last dead time.

Bound On the way down, the window holds the highest recommendation of the last W seconds, five minutes by default, and only then lets the count fall. A pod that is no longer wanted keeps consuming until the window has passed. The consuming count is the maximum of the recommendation over the last window, before the dead time’s minimum is applied to it.

Put together, and leaving aside the sync period, rounding and the tolerance, the loop is:

Fit While the backlog rises, the maximum does nothing, and the loop acts on the backlog of one dead time ago. While it falls, the window holds the old, larger value, and the loop acts on the backlog of one window ago. The way up has the dead time and the way down has the window. A window five minutes long on a loop whose dead time is ninety seconds makes the down branch more than three times slower than the up branch.

Physical translation. Picture a thermostat that switches the heating on a few minutes late and refuses to switch it off until the room has been warm for half an hour. In a room that loses heat slowly, nobody notices. In a draughty room the heating overshoots every time, then finally switches off, the room cools past the setpoint, and the late start overshoots again. The cure lies in the room rather than the switch: a room that holds more heat, with a reserve large enough that the two delays stop mattering. In a backlog-driven loop that reserve is the standing delay, and the window decides how large it has to be.

Definition 5 -- Hunting: a backlog-scaled loop hunts if a step in demand leaves a pod count that keeps swinging instead of settling

Definition 5 (Hunting). In the idealized loop, a loop hunts if the deviation produced by an upward step in demand, starting from rest, does not decay. In the deployed loop, measured over the last hour of a twenty-hour run, a loop hunts if its pod count swings by more than 30% of the pods needed.

The 30% threshold sits well above the ripple the 10% tolerance leaves in a settled loop, so tolerance ripple is not counted as hunting.

Proposition 4 -- Window Scaling Law: whether the backlog loop hunts depends only on the standing delay and the window, each measured in dead times; it rests iff D is at least tau times Phi(W/tau), a curve computed numerically

Proposition 4 (Window Scaling Law). In the idealized min-max loop, whether a disturbance decays does not depend on its size. Measured in units of the dead time, the only parameters are and . If, for each w, the gains at which the loop rests form an interval, as the integration finds for every w computed, there is a function of w alone such that the loop rests after an upward step if and only if

Proof. Around rest write . Both the minimum and the maximum commute with adding a constant, so the constant passes through them, and with . Both also commute with multiplying by a positive number, so F is positively homogeneous: if x is a solution, so is for every . Decay or growth therefore does not depend on the size of the disturbance. Rescaling time by leaves and as the only parameters. For each w, the integration finds a single critical value below which the loop rests and above which it hunts, and . The scaling is proven; the single crossing is observed numerically for every w computed, not proven.

Bound The scaling is exact for the idealized loop. It reduces a question with five parameters, demand, rate, threshold, dead time and window, to one curve of one variable. The curve itself has no exact closed form that this post could find. It is computed by integrating the idealized loop and bisecting on the gain, and two approximations derived below describe it to within a few percent.

The computed curve, with the two approximations derived later in this post and two reference lines:

Φ(w), the standing delay the idealized loop needs, against the window measured in dead times. Blue points: computed. Green dashed: the closed form of Proposition 5a, derived by harmonic balance and drawn for w from 1 to 6. Orange dashed: the asymptotic form e/(1 + c/ln²(w/w₀)), whose shape comes from the roots near e and whose two constants are fitted on w from 10 to 400. The two approximations bracket the default window from opposite sides. Model: the idealized min-max loop with no sampling, rounding, tolerance or ceilings.

Estimate The default case reads straight off the curve. A 300-second window over a 90-second dead time is w = 3.33, and . The loop needs a standing delay of about 82 seconds, nearly one full dead time. At w = 1 it needs 0.57 dead times, which is looser than the of the pure-delay loop. In the idealized loop a window as long as the dead time costs less than a pure delay on both branches would. Longer windows cost more, and the cost grows roughly with the logarithm of the window.

Estimate Short windows need very little. At a tenth of a dead time the idealized loop needs only 0.119 dead times of standing delay. With no window at all, it still rested at a standing delay of an eighth of a dead time, the shortest tested. The standing delay a backlog loop needs is created by the window, and the larger the window, the larger the delay.

What the Deployed Loop Does

The idealized loop has no sync period, no rounding, no tolerance, and no minimum or maximum. The deployed loop has all of them. The test is whether the curve still predicts where the deployed loop stops hunting.

Estimate The deployed model runs the ported external-metric calculation and the ported normalizer, with its stabilization window, every 15 seconds. The backlog is refreshed every 15 seconds. Demand steps from fifty pods’ worth of work to two hundred pods’ worth and stays there. The ceiling is 2,000 replicas and the floor is one, so a single pod is half a percent of the need and rounding does not pose as oscillation. For each pair of dead time and window, bisection finds the smallest standing delay at which the loop does not hunt.

Fit The deployed loop also samples. A change in the backlog reaches a decision up to one sync later, and a decision reaches the scale target at the next. The comparison adds a sampling delay of 10 seconds to both branches, so the effective dead time is and the effective window is . The 10 seconds is one value chosen for all sixteen pairs, not fitted pair by pair.

The observed boundaries, first against the window and then against the curve. Each comes from bisection over twenty-hour runs, with hunting as in Definition 5.

The smallest standing delay at which the deployed loop stops hunting, against the window, for four dead times. Every line rises: the window added to calm the loop raises the delay the loop needs to be calm. Model: ported controller, 15-second sync, demand stepping from 50 to 200 pods needed, last hour of a 20-hour run.
Each point is one pair of dead time and window; for each dead time, the windows of 60, 120, 300 and 600 seconds give the four points from left to right. Points on the dashed diagonal would be perfect predictions; the shaded band is ±10%. The prediction lands within 6% on average and 14% at worst. Every miss larger than 10% is an overestimate, three of the four at the two shortest dead times. Model: ported controller, 15-second sync, demand stepping from 50 to 200 pods needed, last hour of a 20-hour run.

Estimate The curve carries over. Across dead times from 30 seconds to three minutes and windows from one minute to ten, the idealized law with one fixed sampling correction lands within 6% of the deployed boundary on average. The largest miss, 14%, is at the shortest dead time, where the 10-second correction is a third of the dead time itself and the sync period matters most. The misses are not random: all four larger than 10% overestimate the boundary, three of them at the two shortest dead times, so the correction is too large there. In every line of the first figure the boundary rises with the window. At a 90-second dead time, going from a one-minute window to a ten-minute one more than doubles the standing delay the loop needs, from 47 seconds to 98.

Estimate The opening’s puzzle now has its answer. With no window, the idealized loop rested at the smallest standing delay tested, but the deployed loop is never quite without a window. Its sampling adds about 10 seconds to the down branch, a window of a tenth of the effective dead time. The curve says that window needs a standing delay of 0.119 effective dead times, about 11.9 seconds. The deployed loop without a window hunts at a standing delay of 10 seconds and rests at 40, which is consistent with that prediction. Its band at 40 seconds, 186 to 234 pods, is a swing of 24% of the need. That is under Definition 5’s 30% threshold, though wider than the ripple of a loop well above its boundary. The default of one second sits far below either line, which is why removing the window did not save it.

Estimate With the default window the prediction is 89.6 seconds and the observed boundary is 85.5. A queue consumer at a 90-second dead time with the default window needs each pod to carry about ninety seconds of work before it will rest. Forty seconds, the opening’s second attempt, is less than half of that.

The boundary figures show where the hunt starts. The next figure shows its shape.

Pods each minute over the last hour of a twenty-hour run, with demand flat at two hundred pods' worth, a 90-second dead time and the default 300-second window. Red, a standing delay of 60 seconds: the loop hunts in a regular cycle. The window holds each peak for six to seven minutes, the count collapses when it lets go, and the rebuild waits on the dead time. Green, 150 seconds: the count sits at the need all hour.

Estimate The hunting run is a regular cycle rather than noise, and each phase belongs to one branch. The plateau at 350 pods is the window holding a peak that the backlog no longer supports. During it, 350 pods drain a backlog that 200 could have held, so when the window lets go, the backlog is far below rest and the recommendation collapses. The rebuild then waits on the dead time, so the backlog overshoots again and the next peak is set. The cycle repeats every ten to eleven minutes for the whole hour, with demand perfectly flat.

Estimate The figure repeats the range of the hunt that the cost figure below gives as percentiles. What only the figure shows is that the hunt is a limit cycle with a fixed shape. Its longest phase is the window doing exactly what the documentation says it does.

The Window That Cannot Matter

The scaling law says the needed delay grows with the window. It does not say whether it grows without limit. A window of an hour, or a day, would by that reading demand an ever larger standing delay. A second result caps it.

Proposition 5 -- Window-Proof Delay: with a standing delay of at least e dead times, the loop recovers from an upward step without overshoot, so the window never acts, whatever its length

Proposition 5 (Window-Proof Delay). In the idealized min-max loop, let demand step upward from rest. If , the backlog rises monotonically to its new rest level without overshoot, the window never holds a recommendation above the current one, and the loop behaves exactly as the pure-delay loop does, for every window length. Consequently for every w.

Proof sketch. The step moves the rest level, not the backlog: L is continuous and equal to its old rest value for every time before the step, so in the coordinates of the new rest level the whole history, over the window and the dead time, is a constant , and the operator returns throughout the first dead time. While x is nondecreasing, the maximum over any window of x is its latest value, and the minimum over the dead time of a nondecreasing function is its value one dead time ago. So as long as x keeps rising, the min-max loop is the pure-delay loop . It remains to show that the pure-delay solution from a constant negative history keeps rising and never crosses zero when .

Write , so y starts at 1 and obeys the same equation. While y stays positive, let . Then . On the first dead time , so . If over the past dead time, then , and continuation carries the bound forward. A bounded keeps y positive, and makes it decrease to zero. So x rises monotonically to zero, the window never acts, and the loop rests. Since the loop rests at every , the boundary of Proposition 4 can be no higher, and .

Estimate The integration agrees at the edge. At a standing delay of exactly e dead times, the idealized loop after an upward step never rises above rest, at windows of 1, 10 and 100 dead times. At a standing delay of 2.5 dead times, slightly below e, the backlog overshoots rest by 0.07% at every window. At 2 dead times it overshoots by 4.1%. The overshoot itself does not depend on the window, because the window has not yet acted when it happens. What the window does is hold that overshoot.

Fit This explains the shape of the curve. Below e dead times the pure-delay response overshoots a little. The window then holds the pods of that overshoot for its whole length, and the backlog drains below rest in proportion to the overshoot times the window. A longer window turns a smaller overshoot into the same undershoot, so the delay needed creeps upward with the window, roughly logarithmically, toward a ceiling. That ceiling is the delay at which the overshoot vanishes, and Proposition 5 says no window can push the boundary past it.

Estimate The long-window runs agree with that picture as far as they go. At standing delays of 2.8 and 2.2 dead times, a disturbance decayed at every window tested, up to a thousand dead times. At 1.7 dead times it grew from a window of a hundred dead times onward. The integration covers three thousand dead times, so at the longest windows it sees only three window lengths. The runs are consistent with a boundary that approaches e from below as the window grows. They cannot prove that limit on their own, and Proposition 5b below does; the next section gives the order of growth.

Bound Proposition 5 has a precise scope. It covers the idealized loop after an upward step from rest, which is the disturbance the boundary was defined on. A downward step engages the window by construction, because the backlog falls and the window holds the old pods. A loop that is already hunting starts from a history that is not constant. Neither case is covered.

The two ends of the curve can be settled exactly. Without a window the loop has nothing to hold, and with a long enough window any overshoot at all is held long enough to grow.

Proposition 5b -- The Ceiling Is Exact: with no window the loop rests at every standing delay, and below e dead times some window always makes it hunt, so e is the least upper bound of Phi

Proposition 5b (The Ceiling Is Exact). In the idealized min-max loop, let demand step upward from rest. With no window, , the backlog settles at its new rest level at every standing delay, so . For every standing delay there is a finite window that makes the loop hunt. Together with Proposition 5,

Proof sketch. Write x for the backlog relative to its new rest level and , so that as in Proposition 4, with x equal to over the whole history.

Without a window, F is the minimum of x over the last dead time. While x is below rest, that minimum is negative and x rises. If x never crosses rest, it increases to a limit, and the limit must be rest, since any negative limit would keep it rising at a fixed rate. If it crosses at , it keeps rising until , after which the last dead time holds only values above rest. From then on F is positive and at most x, so : x falls, never below , and by the same limit argument settles at rest. In neither case does it swing back below rest, whatever a is.

With a window W and , the loop is the pure-delay loop for as long as x rises, as in the proof of Proposition 5. Every solution of that equation oscillates exactly when [6] , so x crosses rest and reaches a first maximum at some . Neither nor depends on W, because the window does not act while x rises. For the next W seconds the window holds a recommendation of at least , so once a dead time has passed, . By , x has fallen to at most , which lies further below rest than the step that started it as soon as . A disturbance that comes back larger than it started is not settling, and by the positive homogeneity of Proposition 4 the same holds at every scale. So for every some window makes the loop hunt, and . Proposition 5 gives the reverse inequality.

Bound The proposition settles both ends of the curve and leaves its middle open. Without a window there is no delay on the way down, and the loop cannot hunt at any threshold, however small. Below e dead times every overshoot is positive, and a window that holds it for long enough always wins. What remains unproven is that the curve rises steadily in between, and that the stable gains form one interval for each window. Both are observed for every window computed and neither is claimed.

Estimate The computed curve behaves as the proposition requires at both ends. It falls to 0.119 dead times at a window of a tenth of a dead time, and with no window the idealized loop rests at a standing delay of an eighth of a dead time, the smallest tested. At windows of 100 and 400 dead times it reaches 1.719 and 1.938, still below e and still rising. The window that the proof needs grows as the overshoot shrinks, and near e dead times the overshoot is exponentially small, which is why the approach is so slow.

Deriving the Curve

The computed curve can be approached from both ends. For moderate windows the hunt looks like a sine, and a classical method of nonlinear control, the describing function, turns that into a closed form. For long windows the hunt becomes a plateau, and the structure of the delay equation near e dead times gives the shape of the approach.

Proposition 5a -- Harmonic-Balance Approximation: if the hunt is close to a sine, the phase condition holds when its half-period equals the dead time plus the window, and Phi has a closed form for windows of at least one dead time

Proposition 5a (Harmonic-Balance Approximation). Approximate the hunt of the idealized min-max loop by a single sinusoid, as the describing-function method does. Measure time in dead times. For , the phase condition for a self-sustained oscillation is met at angular frequency , a half-period of one dead time plus one window, and the approximation to the curve is

Proof sketch. Feed , , through . At the dead time spans an angle and the window an angle . The output is then flat at for , follows up to , stays at 1 up to , and follows back down. That function is even in , so its fundamental is a pure : the operator lags the sine by exactly a quarter period, which the loop’s integrator needs for a self-sustained oscillation. Integrating the four pieces against gives . Keeping only the fundamental is fair because the loop’s integrator divides the k-th harmonic of the operator’s output by k relative to the fundamental, so the harmonics the balance ignores reach the backlog attenuated. Balancing against requires , and .

Estimate The closed form is accurate where its assumption holds. It gives 0.579 at one dead time against a computed 0.573, 0.779 at two against 0.773, and 0.946 at the default window of 3.33 against 0.916. The errors are 1.0%, 0.8% and 3.3%, always on the high side. It then degrades quickly: 9.3% high at five dead times and 34% at ten. The reason is visible in the figure of the hunt above. A long window turns the cycle into a plateau with short transitions, which is nothing like a sine. The closed form then grows without bound, while Proposition 5 caps the true curve at e.

Bound Long windows are governed by what happens just above a standing delay of e dead times. Write the gain as . At the characteristic equation of Proposition 3 has a double real root at . For small , expanding to second order around it splits the double root into . At the exact root is against the approximation .

Fit That root explains the shape of the curve at long windows. A response with decay rate one and frequency first overshoots after about half a period, so its overshoot is of order : exponentially small near e dead times. The window holds that overshoot and multiplies it into an undershoot by a factor that grows only polynomially with the window. Instability therefore needs to shrink only like , which gives . That is a creep toward e so slow that it looks logarithmic over any range anyone will configure. This step is an argument about orders of growth, not a proof.

Estimate The argument fixes the form, and two constants fitted to the computed curve fix the rest. The expression , with and , matches the computed curve within 0.46% for every window from 10 to 400 dead times. The computed values there include 1.719 at 100 and 1.938 at 400. Toward shorter windows it errs low, by 1.4% at five dead times and 2.7% at the default 3.33. At the default the two approximations bracket the truth from opposite sides: 0.891 from the asymptotic form and 0.946 from the closed form, against a computed 0.916.

The Price of Stability

The law turns the window into a cost. A loop that must rest has to carry a standing delay of , paid by every message as waiting time. A loop that carries less hunts, and hunting has a price of its own.

At a 90-second dead time and the default window, with two hundred pods needed, three quantities change together as the standing delay crosses the boundary:

The price of stability at a 90-second dead time and the default window. Blue band: pods from the 5th to the 95th percentile over the last hour, noise-free. Orange outline: the same band with 30% demand noise. Red: mean pods, noise-free, labelled with its excess over the need. Every quantity changes regime at the same standing delay, and noise widens the band without moving the boundary.

Estimate Hunting is expensive as well as noisy. The window holds every peak, so the hunting loop pays for its highest pod count for minutes at a time and its lowest only briefly. Its mean pod count runs 17% to 99% above the need. A team that cut the threshold to lower latency, and watched the loop hunt, pays for the hunt in pods. It does not get the latency either, because a hunting loop’s backlog swings with its pods.

Definition 6 -- Stability Achievable Region: each choice of threshold and window maps to a standing delay and a mean pod count; the frontier is the set no other choice beats on both

Definition 6 (Stability Achievable Region). For a queue consumer with dead time , each choice of threshold T and window W maps to the point

whose first coordinate is the standing delay and whose second is the mean pod count over the pods needed. A point is on the frontier if no other choice has both a smaller standing delay and fewer mean pods.

Fit The frontier is short and sharp. Below the boundary every point is dominated: it has a smaller nominal delay, but its pods cost more and its waiting time swings. At the boundary the mean pod count falls to the need and stays there. Above it, more standing delay buys nothing. For a fixed window, the boundary is the one point a design should aim at, plus a margin for the error of the law.

Estimate The window moves that point. At a 90-second dead time the deployed boundary is 47 seconds with a one-minute window, 63 seconds with two minutes, 86 seconds with five and 98 seconds with ten. The window chosen to suppress flapping sets the latency floor of the loop. A one-minute window roughly halves the waiting time the default demands.

Corollary 3 -- Latency Floor: a backlog-scaled consumer sized by acceptable latency rests only if that latency is at least tau times Phi(W/tau); any latency of e dead times or more is safe against upward steps whatever the window

Corollary 3 (Latency Floor). Size the threshold by the rule of acceptable latency over time per message. Then the standing delay equals the acceptable latency , and by Proposition 4 the idealized loop rests after an upward step only if

By Proposition 5, every satisfies it for every window. In the deployed loop the dead time and window are replaced by their effective values with the sampling delay added.

Proof. Definition 4 and the sizing rule give . Substitute into Proposition 4 and Proposition 5.

Fit The corollary turns a stability question into a promise a team can check. Take a service level that promises messages processed within ten seconds, on a consumer whose pods take ninety seconds to start, under the default window. The autoscaler cannot keep that promise at rest. The hunt has one cause: ten seconds is less than the dead time times the curve. Either the latency target grows, the window shrinks, or the dead time falls. The threshold is just where the choice shows up.

Estimate Take one point of the boundary figure as a worked design. A consumer whose pods take a minute to start, under a two-minute window, needs a standing delay of about fifty seconds. The deployed boundary is 49.3 seconds and the corrected curve predicts 52.6. A team that promises messages within a minute can meet that, with a margin of 14% over the corrected prediction. A team that promises thirty seconds cannot, at that window. Shortening the window to one minute brings the deployed boundary down to 41.1 seconds, still above thirty. To promise thirty seconds, this consumer needs a shorter dead time, and no setting of the autoscaler can supply one.

Fit The standing delay is not only a cost. A backlog of two and a half minutes of work per pod is also a buffer, and the noise runs below show it doing that job. At a standing delay of 150 seconds and demand noise of 30%, the pods of the loop above its boundary ranged from 165 to 270. Below the boundary, the same noise produced swings roughly three to five times as wide. The delay a window demands buys calm twice over, once by removing the hunt and once by absorbing the noise. It is still paid in waiting time on every message, and whether that price is acceptable is a question about the service, not about the controller.

Fit This prices the belief. The window was added to make the loop stable. In a loop driven by a backlog, it is the reason the loop needs a standing delay to be stable at all. Without it, the idealized loop rested at the smallest standing delay tested, an eighth of a dead time. With the default five minutes, it needs nearly a dead time of waiting on every message. The window does suppress flapping. It does so by moving the instability somewhere the documentation does not look: into the threshold.

What Is Already Known

Bound Backlog per instance is documented practice, and so is the latency rule for choosing its target[4] . The guide also notes why a raw queue length is the wrong metric: “A policy that targets only the number of messages in the queue does not account for the number of instances available. As a result, it can add more instances than needed while the queue drains.”

Bound The distinction between a backlog signal and a rate signal is also known. KEDA’s RabbitMQ scaler offers both, a queue-length mode “to keep number of messages in the queue at selected, nominal level” and a message-rate mode “to keep the consumption rate up to messages publication rate”[2] .

Bound Instability from delayed queue feedback is long established in networking. Hollot and three coauthors analysed active queue management for TCP flows as a feedback loop with delay and designed controllers for its stability[7] . Johari and Tan studied end-to-end congestion control[8] . Delay limits its stability there too. Stabilization windows against flapping are documented by the controller’s own maintainers.

Bound Both effects of this post were met before, with physical servers and a 260-second setup time. Gandhi and three coauthors scaled up on the number of requests in the system, a backlog-type signal. They report that their policy “overshoots while scaling up capacity”, and that “a doubling of request rate can lead to a tripling of number of requests at a single server”[9] . That is the ordering past pods already on their way that the walked-through sync shows. For scaling down they made the servers “very conservative in turning servers off”, a delayed turn-off that is the stabilization window’s ancestor, and it helped a controller driven by request rate. The law of this post concerns the combination they did not study: the window in front of a loop driven by the backlog itself.

Exact Results for Setup Times

Bound Queueing theory has exact results for a close relative of this loop. Gandhi, Harchol-Balter and Adan derived them for server farms[10] . Their model is a farm of k servers with Poisson arrivals, exponential job sizes and an exponential setup time. Idle servers turn off at once, and every job not being served puts one server into setup. Their on-demand policy is therefore a backlog-scaled loop with a threshold of one job per server and no window. Their first theorem covers staggered boot, with at most one server in setup at a time. Response time is then exactly the response time of the same farm without setup plus one setup time, a decomposition they call “counter-intuitive since not all jobs will experience the setup time”. Their second gives the exact state distribution when the number of servers is unlimited.

Estimate Those results give the simulation machinery of this series an exact test. The check is a request-level simulation, with three seeds of 300,000 jobs each and time measured in mean job sizes. Across six configurations, three of each model, five of the six exact values lie inside the range of the seeds: at a mean setup of 1 in the staggered model, for example, the exact 2.205 sits between 2.195 and 2.221. The sixth, at a mean setup of 5, lies 1.7% above the seeds’ mean. Long setups make each busy period long, so a run of fixed length holds fewer of them. The seeds spread by 3% and 5% at setups of 5 and 10, against about 1% at a setup of 1. Where exact theory exists, the simulator agrees with it to within that noise.

Fit The exact theory and this post agree where they overlap, and they measure different things. Their models are stationary Markov chains. With no window, the on-demand policy settles at every load below capacity, as the idealized loop of this post rests without a window. In the staggered model the setup time costs every job one mean setup on average, an exact and modest price, and it never makes the system hunt. Hunting needs what those models leave out: a window that holds capacity after the need has passed, in front of a threshold that reacts through a fixed dead time. Their conclusion for the expensive corner is one this series shares: when both the load and the setup time are high, “it pays to leave servers on”.

Bound The idealized loop also belongs to a known class of equations. A differential equation whose right-hand side depends on the maximum of the unknown function over a past interval is called a differential equation with maxima. Bainov and Hristova collect the theory of that class[11] . The book gives existence results, integral inequalities and stability conditions of the Lyapunov and Razumikhin kind. The loop of this post nests a minimum over the dead time around a maximum over the window, and the scaling law, the ceiling of e and its exactness were derived here for that nested form directly. Whether the general theory already contains them, or yields a proof of the parts left open, was not settled for this post.

Fit Four results were not found in the sources read for this post. The first is the min-max form of the deployed loop, with its different delays on the two branches. The second is the scaling law that reduces its stability to one curve in the window over the dead time, checked against the deployed controller. The third is the window-proof delay of e dead times, its exactness as the least upper bound of the curve, and the latency floor that follows. The fourth is the closed-form harmonic-balance approximation to the curve and the logarithmic approach to e. They are offered as this post’s contribution, with the same caution as before: absence from the sources read does not prove nobody has written them down.

What the Model Leaves Out

Estimate Noise widens the band without moving the boundary. Demand in the boundary runs is flat, so they were repeated with 10% demand noise, a multiplier redrawn every minute. At a standing delay of 40 seconds the band widens to 92 to 581 pods, and at 100 seconds it stays narrow, 183 to 229, so Definition 5 still separates the two sides. Polling the queue every 30 seconds instead of 15 changes little: 60 to 479 pods at 40 seconds and 184 to 207 at 100. At 30% noise, the band the cost figure draws, the noise alone swings a loop above its boundary by more than Definition 5’s threshold: 158 to 284 pods at 100 seconds. The definition cannot place a boundary there. The band still narrows sharply between 80 and 100 seconds, where the noise-free boundary lies.

Estimate Above the boundary the pods follow the noise, and the band widens with it. Below the boundary the swing is several times larger than the noise alone would cause. Polling the queue every 30 seconds instead of every 15 leaves the boundary in place. Noise neither creates the hunt nor removes it, and only decorates whichever regime the standing delay has chosen.

Estimate A consumer-group rebalance is different. When the number of consumers changes, a Kafka group pauses consumption while partitions are reassigned. With a 10-second pause after every change, the loop still swung between 158 and 299 pods at a standing delay of 100 seconds, and rested between 186 and 206 at 150. With a 30-second pause it was irregular at 120 and 150 seconds, and rested at 200. A plausible reading is that the pause must fit inside what the tolerance band can absorb, roughly a standing delay above the pause divided by the tolerance. That reading is untested beyond these runs and is not part of the law.

Fit Several things are not in the model at all. The partition cap clips a Kafka hunt at the partition count. Activation from zero, which KEDA handles with its own cooldown, is not modelled. Neither are metric caching between the broker and the controller, visibility timeouts on SQS, or messages that fail and return to the queue. Each could shift the boundary. None is likely to remove it, because the integrator and the two delays are still there.

The Binding Constraint, in Sequence

Fit The Constraint Sequence Framework orders the constraints by which binds first. For a backlog-scaled consumer the dead time binds first, because it sets the unit in which every other delay is measured. The window binds second, because it sets how many dead times of standing delay the loop needs. The threshold binds third: it is where the needed delay is paid, and its value follows from the first two. The latency a team promises is the outside constraint the other three must fit under.

Fit The common response to a hunting consumer runs the sequence backwards. It lengthens the window to stop the flapping, which raises the delay needed, which makes the hunt worse at the same threshold. The sequence says to start from the dead time and then shorten the window to the shortest one that still serves its purpose. Only then does the threshold get set, from the curve, with a margin.

Falsification Criteria

Each criterion names an observation on a real cluster that would refute this post. All need a queue consumer scaled through KEDA on its backlog, with a measured dead time and a known per-pod rate c, so that the standing delay T/c can be set directly. The cluster kit for this series provides one. Its broker generates demand that steps from 50 to 200 messages a second, and each consumer takes ten messages a second after a 90-second start delay, so 20 pods are needed under a ceiling of 100. The predictions are the deployed model’s at that size, over the last hour of a twenty-hour run.

F1 (the boundary exists).

F2 (the window raises the boundary).

F3 (removing the window does not rescue the default).

The Ledger of What the Loop Cannot Know, Continued

Size

Belief: the metric measures load. Law: at most one factor of g per round, from One Bit per Dead Time. Bill: fifteen rounds for a thirtyfold overload at the default target.

Time

Belief: a stabilization window makes the loop stable.

Mechanism: a backlog integrates every shortfall; the controller reads it through the dead time on the way up and through the window on the way down, and orders for work that pods already on their way will take.

Law: Proposition 4, rest if and only if , with for the default window over a 90-second dead time. Proposition 5, for every window, and Proposition 5b, e is the least upper bound and no window means no hunt. Corollary 3, the same bound on any acceptable latency the threshold is sized from. Layer 1 for the scaling and the bounds; Layer 3 for the curve and the deployed boundaries.

Bill: about ninety seconds of waiting on every message at a 90-second dead time with the default window, or a hunt that costs 17% to 99% more pods. The default threshold of ten messages per pod carries about one second.

What This Post Did Not Claim

Where Each Claim Holds

Claim. The deployed backlog loop rests if and only if its standing delay is at least about .

Claim. The scaling law and the window-proof delay of e dead times are exact for the idealized loop.

Claim. Removing the window does not rescue a threshold far below the dead time.

Reversal condition. The advice of this post is to treat the window as a cost and to size the threshold from the curve. It reverses when the backlog is not what the controller reads. A rate signal has no integrator in it, and a loop scaled on rate does not need a standing delay. That loop has a blindness of its own, which this post does not examine.

What Is Not Claimed

The laws in this post are exact for the idealized loop and checked, not proven, for the deployed one. The scaling of Proposition 4, the ceiling of Proposition 5 and its exactness in Proposition 5b are proofs. That the curve rises steadily between its two ends is observed, not proven. The curve itself is computed, its closed form is an approximation by harmonic balance, and its long-window form is fitted to a shape the argument only motivates. The sixteen deployed boundaries agree with the curve to 6% on average, and the four that miss by more than 10% all miss in the same direction.

It has not been claimed that stabilization windows are harmful. In front of a controller driven by a noisy rate, a window does what the documentation says, and Gandhi and coauthors found a delayed turn-off helpful for exactly that kind of controller. The claim is narrower: in front of a loop driven by a backlog, the window sets a price in waiting time that the documentation does not mention. Nor has it been claimed that KEDA’s defaults are wrong. Ten messages per pod is one second of waiting only for a consumer that handles ten messages a second. A consumer that spends ten seconds on each message carries a hundred seconds at the same threshold, and at a 90-second dead time it rests.

Three open questions are named on purpose. The limit of the curve at very long windows is argued, not proven. The second condition that consumer-group rebalances add is preliminary. And partition caps, metric caching and visibility timeouts are outside the model entirely.

Two credits belong here as well. The symmetric bound that Anti-Fragile Decision-Making at the Edge applied as a sufficient filter is also necessary for the pure-delay equation, which this post uses as its baseline. And the overshoot of a backlog-type signal during setup was observed with physical servers more than a decade before this series; this post adds its boundary, not its discovery.

What Is Left Open

A backlog sees the size of an overload, which the saturated metric of the first part could not. The price is that it remembers. Every second of shortfall stays in it until capacity removes it. A controller that reads it through two different delays needs a standing quantity of that memory just to rest.

Sizing the threshold correctly leaves one assumption standing. Every message in the backlog was waiting for a client who still wanted it. In the model, messages wait patiently for as long as it takes. Real requests time out, and real clients retry. A signal that counts only fresh demand avoids the backlog’s memory entirely. Whether it also avoids what the backlog was remembering is a question this post does not answer.

Click a theme to branch it out and read its points. Each point shows the full path back to the root.

The Delay a Window Demands
Read the Cognitive Map as plain text

The Loop as It Runs

  1. The Kubernetes documentation presents the 300-second downscale window as the fix for flapping, and KEDA passes it straight through to the autoscaler.
  2. KEDA’s AverageValue metric makes the controller compute whatever the current count. Pods on their way do not shrink the backlog, so the controller keeps ordering through the dead time.
  3. At rest every pod carries T messages, so every message waits seconds by Little’s law. The Kafka default of 10 messages per pod at 10 messages a second is one second. The AWS rule of acceptable latency over time per message sets D equal to the acceptable latency.

The Law

  1. Rising, the loop acts on the backlog one dead time ago; falling, the window makes it act on the backlog one window ago. The loop is .
  2. Both filters commute with shifts and positive scaling, so stability depends only on D and W in dead times: the loop rests iff . ; a closed form derived by harmonic balance is within 3.3% from one dead time up to the default window, and the deployed controller matches within 6% on average across sixteen dead time and window pairs.
  3. With the loop recovers from an upward step without overshoot, the window never acts, and so for every window. The ceiling is exact: below e some long enough window always makes the loop hunt, and with no window it rests at any delay.

The Bill

  1. Below the boundary the window holds every peak: at a 90-second dead time and the default window, mean pods run 17% to 99% above need, in a limit cycle about ten minutes long.
  2. At a 90-second dead time the boundary is 47 seconds with a one-minute window and 86 seconds with the default five. A latency target sized below cannot be held at rest.
  3. Sampling still delays the down branch by about 10 seconds, which needs about 11.9 seconds of standing delay; the one-second default hunts with or without the window.

Compute it. Measure your consumer’s dead time, from the scaling decision to the first message a new pod consumes. Add about ten seconds for sampling, and divide your downscale window, plus the same ten seconds, by that effective dead time. Read from the curve, or compute it: up to about three dead times use the closed form of Proposition 5a, which errs high by at most 3.3%, and beyond that use , which errs low by at most 2.7%. The product is the standing delay your loop needs. Divide your threshold by your per-pod rate. If the result is smaller, the loop will hunt, and the cheapest fix is usually a shorter window.

A stabilization window holds pods after the work that justified them is gone. In a loop driven by a backlog, that is the instability, and the threshold is where it gets paid.


[1] The Kubernetes Authors (2026). Horizontal Pod Autoscaling, sections “Stability of workload scale,” “Stabilization window” and “Default behavior.” kubernetes.io documentation (kubernetes/website, main branch).

[2] KEDA Authors (2026). KEDA documentation v2.19: ScaledObject specification; Apache Kafka scaler; AWS SQS Queue scaler; RabbitMQ Queue scaler. keda.sh (kedacore/keda-docs).

[3] The Kubernetes Authors (2025). kubernetes/kubernetes v1.34.0. pkg/controller/podautoscaler/replica_calculator.go and horizontal.go; pkg/controller/controller_utils.go (ActivePodsWithRanks). Released 27 August 2025.

[4] Amazon Web Services (2026). Scaling policy based on Amazon SQS. Amazon EC2 Auto Scaling User Guide.

[5] Little, J.D.C. (1961). A Proof for the Queuing Formula: L = λW. Operations Research, 9(3), 383–387.

[6] Győri, I. & Ladas, G. (1991). Oscillation Theory of Delay Differential Equations: With Applications. Oxford Mathematical Monographs, Oxford University Press.

[7] Hollot, C.V., Misra, V., Towsley, D., Gong, W.-B. (2002). Analysis and Design of Controllers for AQM Routers Supporting TCP Flows. IEEE Transactions on Automatic Control, 47(6), 945-959.

[8] Johari, R. & Tan, D.K.H. (2001). End-to-end congestion control for the internet: delays and stability. IEEE/ACM Transactions on Networking, 9(6), 818-832.

[9] Gandhi, A., Harchol-Balter, M., Raghunathan, R. & Kozuch, M.A. (2012). AutoScale: Dynamic, Robust Capacity Management for Multi-Tier Data Centers. ACM Transactions on Computer Systems 30(4), Article 14.

[10] Gandhi, A., Harchol-Balter, M. & Adan, I. (2010). Server farms with setup costs. Performance Evaluation 67(11), 1123-1138. doi:10.1016/j.peva.2010.07.004.

[11] Bainov, D.D. & Hristova, S.G. (2011). Differential Equations with Maxima. Chapman and Hall/CRC Pure and Applied Mathematics, CRC Press.


Back to top