The ASG scales out when CPU crosses 70%. The launch template references an AMI baked nightly; user data downloads two configuration files from SSM, pulls a 600 MB container image, runs database migrations as a sanity check, then signals the lifecycle hook to mark the instance InService. End-to-end, four minutes from “AWS launches an instance” to “ALB returns 200 from it.” For a smooth diurnal curve, four minutes is fine. For an actual surge — the marketing email at 10:00 sends traffic 4× baseline in three minutes — by the time the new instances are in service the spike is half over, the original fleet has been pinned at 100% CPU, and a quarter of requests timed out waiting. The fix is not to bake a thinner AMI. The fix is warm pools.
What’s happening
A warm pool is a buffer of pre-launched, pre-bootstrapped instances held in the Stopped or Hibernated state alongside the ASG’s active fleet. When scale-out fires, the ASG pulls an instance from the warm pool, starts it, runs whatever the warm-to-active lifecycle hook requires, and puts it InService. The launch path skips the slow parts — AMI provisioning, user-data download, container pull — because those already ran when the instance entered the warm pool.
The state transitions:
- Warm pool creation. Instance launches from the launch template, runs the standard boot sequence (cloud-init, user data, package installs), then transitions to the warm-state lifecycle hook. This is when you finish bootstrap.
- Warm pool resident. The instance moves to
Stopped(EBS preserved, no compute charge),Hibernated(RAM preserved to disk, no compute charge, faster recovery), orRunning(compute charge accrues, fastest recovery — useful only for the most latency-sensitive case). - Warm-to-active transition. ASG decides to scale out. The selected warm instance is started, runs the warm-to-active lifecycle hook (which is where you pull the latest config and signal LB readiness), and the ASG transfers it to the main fleet as InService.
Three resident states, three different trade-offs:
Stopped— costs only the EBS storage while warm. Recovery time is the cold-start of the OS plus your warm-to-active hook. Typically 30–60 seconds. Right for almost all cases.Hibernated— RAM is snapshot to disk on stop, restored on start. The OS does not boot; the kernel resumes where it left off. Recovery time is 15–30 seconds. Costs extra disk space for the RAM dump. Right when the application’s in-memory state matters (JVM warmed up, cache populated) and you want it back faster than a stop/start.Running— full instance cost. Recovery is just the warm-to-active hook, often under 10 seconds. Almost never the right choice for production — at that point you are paying for the instance to do nothing.
When warm pools earn their keep:
- AMIs that are heavy by necessity. A 12 GB AMI takes time to provision into the instance store cache. A warm pool moves that provisioning out of the scale-out critical path.
- Long bootstrap. User-data scripts that pull container images, run migrations, fetch secrets from SSM, register with service discovery. Anything over 60 seconds of bootstrap is a candidate.
- Bursty workloads with sharp scale-out requirements. Marketing emails, ticket on-sale, scheduled batch with a hard SLA on completion time.
- JVM or other warmup-sensitive runtimes. A box that just started has cold caches and unoptimized JIT. Hibernation preserves warmed memory state.
When warm pools are wrong:
- Smooth diurnal workloads. If your scale-out triggers 20 minutes before peak and ramps over an hour, the four-minute bootstrap is irrelevant. The warm pool overhead is wasted.
- Workloads with frequent code deploys. Every deploy of a new AMI invalidates the warm pool; instances cycle, you pay for re-bootstrap, the buffer is empty until the new ones complete. Workable but adds friction.
- Tiny fleets. A warm pool with 2 instances and an ASG that scales 1-at-a-time is overengineered. The threshold is around “ASG with at least 10 instances and meaningful scale-out events.”
How to verify
Check whether a warm pool is configured on an ASG:
aws autoscaling describe-warm-pool \
--auto-scaling-group-name my-burst-asg \
--output json
If no warm pool exists, the call returns an empty WarmPool object. A configured warm pool returns its state, min size, max prepared capacity, and pool state (Stopped, Hibernated, or Running).
To see the warm pool’s actual contents:
aws autoscaling describe-warm-pool \
--auto-scaling-group-name my-burst-asg \
--query 'Instances[].[InstanceId,LifecycleState]' \
--output table
Lifecycle states for a warm instance: Warmed:Pending, Warmed:Pending:Wait, Warmed:Pending:Proceed, Warmed:InService, Warmed:Hibernated, Warmed:Stopped. Warmed:InService is the ready-to-transition state — that is what you want the pool stable at.
To time the warm-to-active transition (the metric that matters), look at the scaling activities and the instance’s launch-to-InService delta:
aws autoscaling describe-scaling-activities \
--auto-scaling-group-name my-burst-asg \
--max-items 20 \
--query 'Activities[?contains(Description, `Warm`)].[StartTime,EndTime,Description]' \
--output table
A healthy warm pool transition completes in 30–60 seconds for Stopped, 15–30 for Hibernated. If you see transitions taking longer, the warm-to-active lifecycle hook is the suspect — usually it is waiting on the LB’s health-check-grace-period rather than doing actual work.
The fix
The lifecycle hook pattern that primes a warm instance properly:
-
Two hooks, two phases. A
EC2_INSTANCE_LAUNCHINGhook fires when the instance is first created and entering the warm pool. A separateEC2_INSTANCE_LAUNCHINGtransition fires again when the instance moves from warm to active. Same hook name, but the lifecycle phase differs. Your hook script must checkOriginandDestinationin the message to know which phase it is handling. -
In the warm phase, do the slow bootstrap: pull container images, run migrations as a sanity check, fetch baseline configuration from SSM, register baseline metrics with CloudWatch. The instance is not in the LB yet, so no production traffic concerns. Use the full hook timeout (one hour) if you need.
-
In the warm-to-active phase, do only the cheap, time-sensitive work: pull the latest configuration (in case it changed while warm), start the application processes, signal readiness to the LB, register with service discovery. Anything that can be done at warm time should already be done.
Configure the warm pool on an existing ASG:
aws autoscaling put-warm-pool \
--auto-scaling-group-name my-burst-asg \
--min-size 4 \
--max-group-prepared-capacity 8 \
--pool-state Stopped \
--instance-reuse-policy ReuseOnScaleIn=true
Field-by-field:
min-size: the minimum number of warm instances to keep at all times. 4 means the ASG always has 4 stopped instances ready, even when the active fleet is at its minimum.max-group-prepared-capacity: total of active + warm. 8 means the ASG keeps active+warm summing to 8 when below this number. Once the active fleet exceeds 8, no warm replenishment happens until it scales back down. This caps the cost.pool-state:Stoppedfor most cases,Hibernatedfor warmup-sensitive runtimes.instance-reuse-policy:ReuseOnScaleIn=truemeans scale-in returns the instance to the warm pool instead of terminating it. Useful for diurnal patterns where you scale down at night and back up in the morning — the same instances cycle, no re-bootstrap.
The lifecycle hook for the warm-to-active phase:
aws autoscaling put-lifecycle-hook \
--auto-scaling-group-name my-burst-asg \
--lifecycle-hook-name warm-to-active \
--lifecycle-transition autoscaling:EC2_INSTANCE_LAUNCHING \
--heartbeat-timeout 300 \
--default-result ABANDON \
--notification-target-arn arn:aws:sqs:us-east-1:123456789012:asg-events \
--role-arn arn:aws:iam::123456789012:role/asg-lifecycle-role
The script on the instance, polling SQS or driven by EventBridge, reads the message, checks the Origin / Destination fields, does the appropriate work, and completes the hook:
aws autoscaling complete-lifecycle-action \
--auto-scaling-group-name my-burst-asg \
--lifecycle-hook-name warm-to-active \
--lifecycle-action-token "${TOKEN}" \
--lifecycle-action-result CONTINUE \
--instance-id "${INSTANCE_ID}"
The one-hour timeout gotcha: lifecycle hooks have a maximum of one hour, full stop. If your warm-phase bootstrap takes longer (it should not, but heavy AMIs with full data hydration sometimes do), you need to extend with record-lifecycle-action-heartbeat calls during the work, each one resetting the timer. Forget the heartbeats and the hook times out, the instance goes to Pending:Wait indefinitely, and the warm pool refuses to grow.
Three rules for warm pool sizing:
Warm pool min-size = peak burst minus active fleet steady-state, rounded up. If the worst case is a sudden jump from 10 active to 18 active, the warm pool needs at least 8 instances ready. Larger if you also need to absorb a second burst before re-warming completes.
Max-group-prepared-capacity caps total cost. Set it to the ASG max-size or just below. The warm pool will not grow past it, which prevents the warm pool from doubling your bill if the ASG max is high.
Replenishment is not instant. When the ASG pulls an instance from the warm pool to active, the warm pool falls below min-size and starts a new launch. That launch takes the full original bootstrap time. If bursts happen back-to-back, the second burst can find the warm pool empty. Size for the worst-case-back-to-back if your workload has that pattern.
The whole machinery is worth the setup time when AMI + bootstrap exceeds 90 seconds and the workload has sharp scale-out events. Below that threshold, faster AMI bake and thinner user data give you the same outcome with less moving parts.
Stack Harbor operates ASG-backed application tiers and cost-capped auto-scaling policies as part of Managed AWS and the Auto-Scaling operating layer. If your scale events are still surprises, start with an infrastructure consult.