What Scale to Zero Cost Me to Build: 50s Sleeps, 14s Wakes

Sep 2, 2026 · 9 min · Ajay Kumar

The most expensive line item in an AI agent platform is not compute while running. It is compute while waiting.

The number that changed how I think about the whole problem: a typical agent sandbox does somewhere between a few seconds and a few tens of minutes of real work per day and then sits there. Forty minutes of work in 24 hours is a 2.8% duty cycle. A warm pool bills you for the other 97.2%.

Everyone agrees with that paragraph. What nobody publishes is the bill for acting on it. Putting a 4 GiB guest to sleep cost me 50 seconds of disk. Waking a real app cost 14 seconds, not the 1.3 I had already measured and reported. This post is those numbers, where they came from, and what each fix bought.

I'm Ajay Kumar, an infrastructure engineer. I built PandaStack, an open-source Firecracker microVM cloud, solo over about six months. Everything below is measured on the production fleet, not modeled.

Why AI traffic breaks the warm-pool assumption

Warm pools exist because cold starts are slow. That logic holds when traffic is smooth: you size a floor of always-on instances, requests arrive continuously, and utilization is decent.

Agent traffic is not smooth. A coding agent runs a burst of tool calls, then blocks for 90 seconds on an LLM round trip. A CI-triggered sandbox lives for four minutes a day. A dev environment is active during working hours and dead for sixteen. The arrival pattern is spiky at the second scale and mostly empty at the hour scale.

Under that shape, a warm pool is not a latency optimization. It is a subscription to unused RAM.

The architectural response is to make the cold path fast enough that you never need the pool. On my platform every sandbox create is a snapshot restore, not a boot: p50 179ms, p99 203ms, with the Firecracker /snapshot/load step itself at 80ms p50. There is no idle inventory. The first spawn of a template does a real cold boot (~3s) and bakes a snapshot; every create after that restores it.

What hibernate actually does

For long-lived things (a deployed app, a managed Postgres), the same trick runs in reverse. Hibernate means: snapshot guest memory to disk, then kill the VM and release the resources.

Concretely, for an app on my base template (4 GiB RAM, 8 burstable vCPU):

  1. Every request that reaches the app bumps a timestamp, throttled to at most one write per minute, so traffic volume never matters and only recency does.
  2. A 30-second reconcile loop finds apps idle past their idle_timeout_seconds (default 900).
  3. Sleeping deletes the sandbox entirely. CPU, RAM, and disk are all released. What survives is the immutable artifact baked at the end of the last successful deploy.
  4. The next real request boots a fresh sandbox from that artifact and forwards the request once the app answers.

I documented the dial itself in how the idle window and wake path are tuned per app and per database. The consequence worth internalizing: because the sandbox is deleted, local disk does not survive a sleep/wake cycle. Anything written at runtime is gone. That is a real constraint, not a footnote.

The honest engineering cost, part one: hibernation is not free

Here is the number nobody advertises. Taking a full memory snapshot of a 4 GiB guest takes about 50 seconds of wall time on my hosts.

That is not my code being slow. Firecracker writes guest memory fully allocated, including the zeros. I measured host disk at roughly 440 MB/s sequential, which puts the hardware floor for 4 GiB at 10.4 seconds. Firecracker takes ~48s, about 4.6x the floor. Fixing that requires either Firecracker's diff snapshots (which need track_dirty_pages set at boot, and my driver hardcodes snapshot_type: "Full" in four places) or a MAP_SHARED lazy-flush patch of the kind CodeSandbox uses.

Two things follow directly from that 50 seconds.

Hibernation time is proportional to memory. An 8 GiB guest costs roughly twice the sleep time of a 4 GiB one, which caps how aggressively a host can put VMs to sleep. Sleeping twenty apps at once means twenty sequential multi-gigabyte writes against the same disk. The same write shows up on the fork path, where snapshotting a parent dominates the wall clock of a fan-out call — I broke that down in what a copy-on-write VM fork really preserves.

A short idle timeout can cost more than it saves. If an app receives a request every 20 minutes and the timeout is 900s, you sleep and wake it around 70 times a day, roughly an hour of background snapshot I/O per app per day. That work is not billed to the customer, but it is absolutely spent by the fleet.

I did claw back the space, just not the time. The pass that builds a snapshot's zero map now punches holes for every all-zero chunk in the same pass, so reclaiming costs no extra I/O. On a real production hibernate a snapshot went from 4096 MiB apparent to 256 MiB allocated, 16x, with only about 6% of chunks non-zero (64-65 out of 1024). Applying it by hand to existing sleepers reclaimed 62 GiB fleet-wide.

The honest engineering cost, part two: wake has a floor

I got this badly wrong once. I optimized the wake primitive on a synthetic sandbox, measured 1.3 seconds, and reported it. Then a user tested a real deployed app and got 14 seconds. The claim was not fabricated, it was measured on the wrong path.

When I instrumented the real wake, both hypotheses I had formed in advance were wrong:

Phase Time My guess
fc_load (Firecracker /snapshot/load) 3111ms not suspected
ssh_ready 458ms "this dominates"
netns rebuild 1ms, never fired "this is the cost"
clock sync 204ms not suspected

The cause of the 3111ms was structural, not a bug in the usual sense. A create restores one shared template snapshot that every sandbox on the host reuses, so it is hot in page cache and loads in ~53ms. A wake restores a private per-sandbox 4 GiB memory image that nothing else touches. An A/B on the same sandbox: cold file 5269ms, pre-warmed file 3060ms. So ~2.2s was cold disk I/O, and ~3.0s remained even fully page-cached, which is Firecracker eagerly populating a private 4 GiB mapping.

Prewarming could not fix the second half. The fix was to stop paying for 4 GiB of guest memory the app never touches, by baking a zero-map header into the snapshot so the restore skips absent chunks. fc_load went 3111ms to 33ms, a 94x improvement. The same defect existed in a second, entirely separate producer of memory images, where a real app's boot was 8798ms with fc_load_snap at 8771ms; after the fix, 130ms and 80ms.

Then the profile flipped again, and the remaining ~11 seconds turned out to be my own orchestration on an already-awake machine: polling on a 500ms ticker, rewriting an env file the running process cannot see, a disk-coherence probe whose global sync spent 3.4s flushing restored page cache, and health checks executed as curl inside the guest with 3-second timeouts and 1-second sleeps between them.

The principle that fixed it is borrowed from AWS SnapStart and CodeSandbox: resume, then patch. Never patch, then resume. A warm-seeded app is already listening when the VM resumes. Gate on the single fact that matters (does it answer HTTP through the proxy) and run env re-delivery and coherence verification behind the commit.

Measured across three full production sleep/wake cycles on a Next.js app: wake 1381ms, 1260ms, 1198ms, with first response at 1185/1101/1064ms. Visitor-perceived, measured with curl and a browser user agent including DNS, TLS and CDN, came in at 1.80s and 2.22s.

That last number is the honest one to quote. Sub-second visitor latency is not reachable while a CDN and TLS handshake sit in front, and I don't promise it.

The wake path has to survive losing the host

A wake that only works on the machine that took the snapshot is not a wake path, it is a cache. This is the part people underestimate.

I run three rungs, tried in order:

  1. Local memory image. Fast (~600ms for the VM primitive) but pinned to one host.
  2. Object-storage tiered memory. An hourly sweep uploads memory images older than 30 days as range-readable objects, writes a small sidecar, and deletes the local copy. A tiered sleeper's local footprint went from 260 MiB to 76 KiB, and waking it streams the working set back over ranged GETs: 4856ms end to end versus ~1.3s local. That is the deliberate bargain.
  3. Rebuild from the immutable deploy artifact. Slowest (~45s) and always available.

Rung 3 is the one that earned its keep. I verified it by resetting a host out from under a sleeping app: the local sandbox and snapshot were gone, the first request auto-recovered by redeploying from object storage, and the app served correctly. Before that fallback existed, that exact case failed permanently.

One honest limit I will not paper over. Tiering frees the memory image but not the root filesystem, which stays local. Measured anatomy of a real sleeper at 928 MiB on disk: 408 MiB of memory image (tierable), 520 MiB of rootfs (not). So tiering frees about 44% of a sleeper's disk, and it is still pinned to its host. "Wakes on any host" is not something I claim.

Active CPU billing and committed memory behave differently

This is the part of the economics that took me longest to see clearly.

My rate card is one line for every workload class: $0.054 per active vCPU-hour and $0.0162 per working-set GiB-hour, identical for sandboxes, apps and managed databases. It used to be three cards anchored to three different competitors, which meant the same Firecracker VM billed 13.5x differently for CPU depending only on which product created it.

CPU and memory are not symmetric under overcommit, and that asymmetry is the whole game.

CPU genuinely time-shares. An idle vCPU costs the host essentially nothing. Every template gets the same 8 burstable vCPUs and shares cores under contention via cgroup weights, so billing active CPU-seconds tracks real cost and the host loses nothing by handing that ceiling out generously.

Memory does not time-share. A resident page is a page nobody else can have. Working-set billing is therefore only honest if you have a mechanism that actually reclaims non-resident pages. Otherwise you bill for 250 MiB while holding 4 GiB, which is not a pricing decision, it is a subsidy that shows up as capacity you cannot sell. The zero-map, the demand-paged restore, and hibernate itself are what make the number you bill and the number you hold the same number.

The arithmetic on 730 hours, using the published rates:

Posture Memory basis Monthly memory cost
Always-on, billed on committed 4 GiB 4 GiB $47.30
Always-on, billed on measured working set (~250 MiB) 0.24 GiB $2.89
Sleeping 0 $0 compute, cents of storage

The gap between row one and row two is entirely a function of whether your platform can prove which pages are live. The gap between row two and row three is scale to zero.

Note which side the risk sits on. The nominal ceiling on my agents is huge (16,384 sandboxes each, bounded by the /30 subnet pool), but the binding constraint is host RAM. Every mechanism above exists to relieve a memory admission problem, not a CPU one.

The failure mode: scale to zero that silently stops working

Two production incidents, both worth stealing as tests.

Apps that never slept. Apps with a custom domain attached refused to hibernate. The domain machinery was entirely innocent. A public domain attracts steady automated traffic (uptime monitors every 1-5 minutes, health probes, crawlers) that an obscure default URL never sees, and each hit reset the idle timer. The fix was a request classifier with two independent bits: does this request keep the app warm, and is it allowed to wake a sleeping app. Named uptime monitors and HEAD requests get neither. Real clients, including crawlers hitting real content, still wake the app, because withholding content from a real visitor is a worse failure than an extra wake.

A knob that drifted. A safety-relevant setting lived only in cloud-init, which writes its config file exactly once at first boot. After a host rebuild the value drifted, database auto-suspend silently stopped running, sleeping databases stayed resident, the memory admission ledger filled, and new deploys started returning 503. Nothing logged an error, because nothing was failing. A thing simply was not happening.

Two generalizations: any knob whose absence degrades capacity belongs in configuration management that re-asserts it every run, never in first-boot provisioning. And a scale-to-zero sweep needs a "scanned N, slept M" metric, because a dead sweeper and an idle sweeper look identical otherwise.

When a warm pool is still the right answer

I am not arguing scale to zero is universally correct. Keep things warm when wake latency lands directly in a human's perception loop and traffic is continuous enough that you would rarely sleep anyway. Keep them warm when local disk state is expensive to reconstruct. Keep them warm when hibernate time is long relative to your idle gaps, because you will spend more host I/O cycling than you save.

Scale to zero wins when the duty cycle is low, the wake path is proven against host loss, and the state model is honest about what a sleep destroys. AI agent workloads sit almost perfectly in that region: bursty, mostly idle, usually reconstructible from a build artifact, and with state that already belongs in a managed database rather than a container filesystem.

The summary I would give my past self: the compute is cheap, the waiting is expensive, and the only way to bill for waiting honestly is to build the machinery that stops doing it.

Related reading


I'm Ajay Kumar, an infrastructure and DevOps engineer with 14 years of experience, and the founder of PandaStack, an Apache-2.0 Firecracker microVM cloud I built solo. I'm on LinkedIn and Upwork, and the code lives at github.com/pandastack-io.

I'm Ajay Kumar — I build and operate PandaStack, an open-source Firecracker microVM cloud for AI agents. Everything above comes from running it in production.

Need this kind of infrastructure work? See what I do or email hello@ajayk.sh.


Keep reading