Autoscaler flapping and the effect of dead time
Simulate a modelNeeds Python
Problem
An autoscaler is a feedback loop: it measures utilisation, adds or removes machines, and measures again. If machines take d minutes to arrive, the loop acts on old information. It keeps adding machines while the ones it already asked for are on their way, overshoots, then removes too many, and the fleet oscillates. Separate thresholds for scaling up and down (hysteresis) and a slow scale-down damp the loop by making it act less on noise and less hastily in the costly direction.
Simulate an autoscaler that targets 60% utilization on a metric averaged over 1 minute, with a provisioning delay of d minutes. Drive it with constant demand and plot fleet size for d = 0, 2 and 5 minutes. Then use separate up and down thresholds with a slow scale-down.
The question. Does the fleet oscillate under constant demand as the provisioning delay grows, and does hysteresis with slow scale-down stop it?
Predict
Expect no oscillation at d = 0 and oscillation with a period of about 2d as d grows. With separate thresholds and a slow scale-down, the oscillation should damp out.
Run it
1Install uv once. It fetches Python and the lab's packages for you.
Terminal curl -LsSf https://astral.sh/uv/install.sh | sh2Get the script. It is the whole lab: read it, then download it.
View the codeHide the code
autoscaler-flapping-and-the-effect-of.py # /// script # requires-python = ">=3.10" # /// # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT # https://thedataengineering.com/lab-code/LICENSE.txt """Autoscaler flapping and the effect of delay. An autoscaler targets 60% utilization on a 1-minute average, and new machines arrive d minutes after they are ordered. Constant demand; fleet size over time for d = 0, 2 and 5 minutes; then separate up and down thresholds with a slow scale-down. """ import argparse, json, math p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--demand", type=float, default=600, help="Constant demand, in units one machine serves at full load.") p.add_argument("--start", type=int, default=400, help="Machines at the start.") p.add_argument("--minutes", type=int, default=120, help="Simulated minutes.") p.add_argument("--delays", type=lambda s: [int(v) for v in s.split(",")], default=[0, 2, 5], help="Provisioning delays d, minutes.") p.add_argument("--json", help="Also write the results to this JSON file.") args = p.parse_args() TARGET = 0.6 def run(d, hysteresis): """The controller sees only utilization, which cannot exceed 100%: an under-sized fleet hides how much it is short.""" fleet, pending, rows, history = float(args.start), [], [], [] for m in range(args.minutes): fleet += sum(n for t, n in pending if t == m); pending = [(t, n) for t, n in pending if t > m] history.append(min(1.0, args.demand / fleet)) util = history[-2] if len(history) > 1 else history[-1] # the average over the last full minute: one minute old desired = fleet * util / TARGET on_order = sum(n for _, n in pending) if hysteresis: if util > 0.7: pending.append((m + d, max(0.0, desired - fleet - on_order))) # counts what is already on order elif util < 0.5: fleet -= (fleet - desired) * 0.1 # slow scale-down else: if desired > fleet: pending.append((m + d, desired - fleet)) # ignores what is on order else: fleet = desired # scale-down is immediate rows.append({"minute": m, "fleet": fleet, "delay": d, "policy": "hysteresis + slow scale-down" if hysteresis else "single target"}) tail = [r["fleet"] for r in rows[-40:]] return rows, max(tail) - min(tail) rows, summary = [], [] for d in args.delays: r, swing = run(d, False); rows += r summary.append({"delay": d, "policy": "single target", "swing": swing}) print(f"d = {d} min, single target: fleet swings by {swing:6.0f} machines in the last 40 minutes") r, swing = run(max(args.delays), True); rows += r summary.append({"delay": max(args.delays), "policy": "hysteresis + slow scale-down", "swing": swing}) print(f"d = {max(args.delays)} min, hysteresis + slow scale-down: swing {swing:.0f}") if args.json: with open(args.json, "w") as f: json.dump({"tables": {"fleet": rows, "summary": summary}}, f)3Run it in the folder you saved it to, with the defaults first. Then change a knob from the table below and run it again.
Terminal uv run autoscaler-flapping-and-the-effect-of.py uv run autoscaler-flapping-and-the-effect-of.py --help # every knob
| Knob · default | What it changes |
|---|---|
| --demand600 | Constant demand, in units one machine serves at 100%. |
| --start400 | Machines at the start. |
| --minutes120 | Simulated minutes. |
| --delays0, 2, 5 | Provisioning delays d, minutes. |
Our run
Data table
| Minute | series | Machines |
|---|---|---|
| 0 | 0 | 400 |
| 1 | 0 | 400 |
| 2 | 0 | 400 |
| 3 | 0 | 400 |
| 4 | 0 | 400 |
| 5 | 0 | 400 |
| 6 | 0 | 400 |
| 7 | 0 | 400 |
| 8 | 0 | 400 |
| 9 | 0 | 400 |
| 10 | 0 | 400 |
| 11 | 0 | 400 |
| 12 | 0 | 400 |
| 13 | 0 | 400 |
| 14 | 0 | 400 |
| 15 | 0 | 400 |
| 16 | 0 | 400 |
| 17 | 0 | 400 |
| 18 | 0 | 400 |
| 19 | 0 | 400 |
| 20 | 0 | 400 |
| 21 | 0 | 400 |
| 22 | 0 | 400 |
| 23 | 0 | 400 |
| 24 | 0 | 400 |
| 25 | 0 | 400 |
| 26 | 0 | 400 |
| 27 | 0 | 400 |
| 28 | 0 | 400 |
| 29 | 0 | 400 |
| 30 | 0 | 400 |
| 31 | 0 | 400 |
| 32 | 0 | 400 |
| 33 | 0 | 400 |
| 34 | 0 | 400 |
| 35 | 0 | 400 |
| 36 | 0 | 400 |
| 37 | 0 | 400 |
| 38 | 0 | 400 |
| 39 | 0 | 400 |
| 40 | 0 | 400 |
| 41 | 0 | 400 |
| 42 | 0 | 400 |
| 43 | 0 | 400 |
| 44 | 0 | 400 |
| 45 | 0 | 400 |
| 46 | 0 | 400 |
| 47 | 0 | 400 |
| 48 | 0 | 400 |
| 49 | 0 | 400 |
| 50 | 0 | 400 |
| 51 | 0 | 400 |
| 52 | 0 | 400 |
| 53 | 0 | 400 |
| 54 | 0 | 400 |
| 55 | 0 | 400 |
| 56 | 0 | 400 |
| 57 | 0 | 400 |
| 58 | 0 | 400 |
| 59 | 0 | 400 |
| 60 | 0 | 400 |
| 61 | 0 | 400 |
| 62 | 0 | 400 |
| 63 | 0 | 400 |
| 64 | 0 | 400 |
| 65 | 0 | 400 |
| 66 | 0 | 400 |
| 67 | 0 | 400 |
| 68 | 0 | 400 |
| 69 | 0 | 400 |
| 70 | 0 | 400 |
| 71 | 0 | 400 |
| 72 | 0 | 400 |
| 73 | 0 | 400 |
| 74 | 0 | 400 |
| 75 | 0 | 400 |
| 76 | 0 | 400 |
| 77 | 0 | 400 |
| 78 | 0 | 400 |
| 79 | 0 | 400 |
| 80 | 0 | 400 |
| 81 | 0 | 400 |
| 82 | 0 | 400 |
| 83 | 0 | 400 |
| 84 | 0 | 400 |
| 85 | 0 | 400 |
| 86 | 0 | 400 |
| 87 | 0 | 400 |
| 88 | 0 | 400 |
| 89 | 0 | 400 |
| 90 | 0 | 400 |
| 91 | 0 | 400 |
| 92 | 0 | 400 |
| 93 | 0 | 400 |
| 94 | 0 | 400 |
| 95 | 0 | 400 |
| 96 | 0 | 400 |
| 97 | 0 | 400 |
| 98 | 0 | 400 |
| 99 | 0 | 400 |
| 100 | 0 | 400 |
| 101 | 0 | 400 |
| 102 | 0 | 400 |
| 103 | 0 | 400 |
| 104 | 0 | 400 |
| 105 | 0 | 400 |
| 106 | 0 | 400 |
| 107 | 0 | 400 |
| 108 | 0 | 400 |
| 109 | 0 | 400 |
| 110 | 0 | 400 |
| 111 | 0 | 400 |
| 112 | 0 | 400 |
| 113 | 0 | 400 |
| 114 | 0 | 400 |
| 115 | 0 | 400 |
| 116 | 0 | 400 |
| 117 | 0 | 400 |
| 118 | 0 | 400 |
| 119 | 0 | 400 |
| 0 | 2 | 400 |
| 1 | 2 | 400 |
| 2 | 2 | 667 |
| 3 | 2 | 933 |
| 4 | 2 | 1,380 |
| 5 | 2 | 1,340 |
| 6 | 2 | 779 |
| 7 | 2 | 542 |
| 8 | 2 | 542 |
| 9 | 2 | 542 |
| 10 | 2 | 696 |
| 11 | 2 | 1,060 |
| 12 | 2 | 1,440 |
| 13 | 2 | 1,250 |
| 14 | 2 | 657 |
| 15 | 2 | 526 |
| 16 | 2 | 526 |
| 17 | 2 | 526 |
| 18 | 2 | 800 |
| 19 | 2 | 1,150 |
| 20 | 2 | 1,460 |
| 21 | 2 | 1,040 |
| 22 | 2 | 594 |
| 23 | 2 | 571 |
| 24 | 2 | 571 |
| 25 | 2 | 571 |
| 26 | 2 | 952 |
| 27 | 2 | 1,330 |
| 28 | 2 | 1,480 |
| 29 | 2 | 785 |
| 30 | 2 | 508 |
| 31 | 2 | 508 |
| 32 | 2 | 508 |
| 33 | 2 | 648 |
| 34 | 2 | 987 |
| 35 | 2 | 1,420 |
| 36 | 2 | 1,380 |
| 37 | 2 | 715 |
| 38 | 2 | 512 |
| 39 | 2 | 512 |
| 40 | 2 | 512 |
| 41 | 2 | 716 |
| 42 | 2 | 1,060 |
| 43 | 2 | 1,450 |
| 44 | 2 | 1,220 |
| 45 | 2 | 652 |
| 46 | 2 | 534 |
| 47 | 2 | 534 |
| 48 | 2 | 534 |
| 49 | 2 | 820 |
| 50 | 2 | 1,180 |
| 51 | 2 | 1,460 |
| 52 | 2 | 1,000 |
| 53 | 2 | 581 |
| 54 | 2 | 580 |
| 55 | 2 | 580 |
| 56 | 2 | 580 |
| 57 | 2 | 967 |
| 58 | 2 | 1,350 |
| 59 | 2 | 1,480 |
| 60 | 2 | 762 |
| 61 | 2 | 500 |
| 62 | 2 | 500 |
| 63 | 2 | 500 |
| 64 | 2 | 657 |
| 65 | 2 | 990 |
| 66 | 2 | 1,430 |
| 67 | 2 | 1,360 |
| 68 | 2 | 707 |
| 69 | 2 | 514 |
| 70 | 2 | 514 |
| 71 | 2 | 514 |
| 72 | 2 | 727 |
| 73 | 2 | 1,070 |
| 74 | 2 | 1,450 |
| 75 | 2 | 1,190 |
| 76 | 2 | 643 |
| 77 | 2 | 539 |
| 78 | 2 | 539 |
| 79 | 2 | 539 |
| 80 | 2 | 838 |
| 81 | 2 | 1,200 |
| 82 | 2 | 1,470 |
| 83 | 2 | 967 |
| 84 | 2 | 570 |
| 85 | 2 | 570 |
| 86 | 2 | 570 |
| 87 | 2 | 589 |
| 88 | 2 | 969 |
| 89 | 2 | 1,360 |
| 90 | 2 | 1,470 |
| 91 | 2 | 757 |
| 92 | 2 | 498 |
| 93 | 2 | 498 |
| 94 | 2 | 498 |
| 95 | 2 | 658 |
| 96 | 2 | 991 |
| 97 | 2 | 1,430 |
| 98 | 2 | 1,360 |
| 99 | 2 | 706 |
| 100 | 2 | 515 |
| 101 | 2 | 515 |
| 102 | 2 | 515 |
| 103 | 2 | 728 |
| 104 | 2 | 1,070 |
| 105 | 2 | 1,450 |
| 106 | 2 | 1,190 |
| 107 | 2 | 642 |
| 108 | 2 | 540 |
| 109 | 2 | 540 |
| 110 | 2 | 540 |
| 111 | 2 | 840 |
| 112 | 2 | 1,200 |
| 113 | 2 | 1,470 |
| 114 | 2 | 963 |
| 115 | 2 | 568 |
| 116 | 2 | 568 |
| 117 | 2 | 568 |
| 118 | 2 | 590 |
| 119 | 2 | 969 |
| 0 | 5 | 400 |
| 1 | 5 | 400 |
| 2 | 5 | 400 |
| 3 | 5 | 400 |
| 4 | 5 | 400 |
| 5 | 5 | 667 |
| 6 | 5 | 933 |
| 7 | 5 | 1,200 |
| 8 | 5 | 1,220 |
| 9 | 5 | 1,020 |
| 10 | 5 | 980 |
| 11 | 5 | 991 |
| 12 | 5 | 744 |
| 13 | 5 | 691 |
| 14 | 5 | 691 |
| 15 | 5 | 691 |
| 16 | 5 | 691 |
| 17 | 5 | 691 |
| 18 | 5 | 691 |
| 19 | 5 | 928 |
| 20 | 5 | 1,240 |
| 21 | 5 | 1,250 |
| 22 | 5 | 1,010 |
| 23 | 5 | 845 |
| 24 | 5 | 957 |
| 25 | 5 | 835 |
| 26 | 5 | 794 |
| 27 | 5 | 794 |
| 28 | 5 | 794 |
| 29 | 5 | 794 |
| 30 | 5 | 794 |
| 31 | 5 | 794 |
| 32 | 5 | 950 |
| 33 | 5 | 1,160 |
| 34 | 5 | 1,180 |
| 35 | 5 | 1,020 |
| 36 | 5 | 883 |
| 37 | 5 | 924 |
| 38 | 5 | 872 |
| 39 | 5 | 872 |
| 40 | 5 | 872 |
| 41 | 5 | 872 |
| 42 | 5 | 872 |
| 43 | 5 | 872 |
| 44 | 5 | 885 |
| 45 | 5 | 1,010 |
| 46 | 5 | 1,130 |
| 47 | 5 | 1,100 |
| 48 | 5 | 978 |
| 49 | 5 | 903 |
| 50 | 5 | 933 |
| 51 | 5 | 902 |
| 52 | 5 | 902 |
| 53 | 5 | 902 |
| 54 | 5 | 902 |
| 55 | 5 | 902 |
| 56 | 5 | 902 |
| 57 | 5 | 967 |
| 58 | 5 | 1,060 |
| 59 | 5 | 1,090 |
| 60 | 5 | 1,020 |
| 61 | 5 | 943 |
| 62 | 5 | 934 |
| 63 | 5 | 927 |
| 64 | 5 | 927 |
| 65 | 5 | 927 |
| 66 | 5 | 927 |
| 67 | 5 | 927 |
| 68 | 5 | 927 |
| 69 | 5 | 955 |
| 70 | 5 | 1,030 |
| 71 | 5 | 1,070 |
| 72 | 5 | 1,040 |
| 73 | 5 | 972 |
| 74 | 5 | 942 |
| 75 | 5 | 945 |
| 76 | 5 | 945 |
| 77 | 5 | 945 |
| 78 | 5 | 945 |
| 79 | 5 | 945 |
| 80 | 5 | 945 |
| 81 | 5 | 955 |
| 82 | 5 | 1,010 |
| 83 | 5 | 1,050 |
| 84 | 5 | 1,040 |
| 85 | 5 | 989 |
| 86 | 5 | 952 |
| 87 | 5 | 958 |
| 88 | 5 | 958 |
| 89 | 5 | 958 |
| 90 | 5 | 958 |
| 91 | 5 | 958 |
| 92 | 5 | 958 |
| 93 | 5 | 958 |
| 94 | 5 | 1,000 |
| 95 | 5 | 1,040 |
| 96 | 5 | 1,040 |
| 97 | 5 | 999 |
| 98 | 5 | 961 |
| 99 | 5 | 966 |
| 100 | 5 | 961 |
| 101 | 5 | 961 |
| 102 | 5 | 961 |
| 103 | 5 | 961 |
| 104 | 5 | 961 |
| 105 | 5 | 961 |
| 106 | 5 | 995 |
| 107 | 5 | 1,030 |
| 108 | 5 | 1,040 |
| 109 | 5 | 1,000 |
| 110 | 5 | 968 |
| 111 | 5 | 967 |
| 112 | 5 | 964 |
| 113 | 5 | 964 |
| 114 | 5 | 964 |
| 115 | 5 | 964 |
| 116 | 5 | 964 |
| 117 | 5 | 964 |
| 118 | 5 | 991 |
| 119 | 5 | 1,030 |
Swing in the last 40 minutes (machines)
| Delay (min) | Policy | Swing |
|---|---|---|
| 0 | single target | 0 |
| 2 | single target | 976 |
| 5 | single target | 109 |
| 5 | hysteresis + slow scale-down | 0 |
d = 0 min, single target: fleet swings by 0 machines in the last 40 minutes
d = 2 min, single target: fleet swings by 976 machines in the last 40 minutes
d = 5 min, single target: fleet swings by 109 machines in the last 40 minutes
d = 5 min, hysteresis + slow scale-down: swing 0What we found
With no delay the fleet settled and stopped (0 machines of swing in the last 40 minutes). With a 2-minute delay it oscillated hard, swinging by 976 machines; with a 5-minute delay it swung by 109, less than at 2 minutes, which the expectation (oscillation growing with d) does not predict; the chart shows a slower, smaller cycle at 5 minutes, and this run does not establish why. Hysteresis with a slow scale-down removed the oscillation at d = 5 (swing 0).
Read it
Delay in a feedback loop turns correction into oscillation; the fix is to act less on each measurement, not to measure more often. Separate thresholds leave a band where the loop does nothing, and a slow scale-down makes the expensive mistake (removing machines that will be needed) rare. The exact swing depends on the delay, the averaging window and the gain; the existence of the oscillation does not.