Modal container capacity Β· priced 14 Aug 2026
Two ways to keep 150 job containers ready for a spike. One pays for readiness around the clock. One pays for it only in the hours spikes actually arrive.
The two options are identical in capability. They differ only in when capacity is warm. Each bar is one hour of the day; bar height is warm containers.
Option B cuts paid container-hours by 51% β 54,600 hours a month that were bought and never used.
| Line item | Container-hours / mo | Option A | Option B |
|---|---|---|---|
| 150 warm β full day (24 h) | 108,000 | $2,363 | β |
| 150 warm β window (10 h) | 45,000 | β | $985 |
| 20 floor β off-hours (14 h) | 8,400 | β | $184 |
| Job compute (already inside the warm pool) | β | $0 | $0 |
| Team plan β required above 100 containers | β | $250 | $250 |
| Total per month | $2,613 | $1,418 |
Jobs run on the warm containers you already pay for, so per-job compute adds nothing. That is the counter-intuitive part: the work is free, the readiness is the bill.
Every hour you hold 150 warm costs $98. Every hour you drop to the 20-floor costs $13. Pick the window and read the price.
The curve flattens because two costs never move: the 20-container overnight floor and the $250 plan. Shrinking the window past 8 hours buys little and costs coverage.
| Scenario | Option A | Option B |
|---|---|---|
| 150 jobs land at once, 14:00 | 0 s wait | 0 s wait |
| 150 jobs land at once, 03:00 | 0 s wait | 130 wait ~25 s |
| 30 jobs land at once, 03:00 | 0 s wait | 10 wait ~25 s |
| 20 jobs land at once, 03:00 | 0 s wait | 0 s wait |
| Jobs beyond 150, any hour | queue | queue |
| Moving parts to operate | none | 2 cron jobs |
Option B is not weaker in the window. It is weaker outside it. So the whole choice reduces to one question for the customer: can spikes arrive at 3 a.m.?
Two scheduled functions flip the warm-pool size. Modal supports this natively; no external scheduler.
@app.cls(min_containers=20, max_containers=150, scaledown_window=600, ...)
class Processor: ...
@app.function(schedule=modal.Cron("50 7 * * *", timezone="Asia/Ulaanbaatar"))
def warm_up():
Processor().update_autoscaler(min_containers=150) # 10 min before the window
@app.function(schedule=modal.Cron("0 18 * * *", timezone="Asia/Ulaanbaatar"))
def cool_down():
Processor().update_autoscaler(min_containers=20)
update_autoscaler values revert to the decorator on the next deploy. Deploy at 10:00 and the pool drops from 150 to 20 mid-window, with no error anywhere. Any deploy inside the window must re-run warm_up.
The cron must fire before the window, not at it. 10 minutes of lead time is why warm_up runs at 07:50 for an 08:00 window.
scaledown_window=600Containers spun up during a spike stay warm for 10 minutes afterwards. A second spike inside that window is instant. This helps both options and costs nothing extra.
Neither cpu= nor memory= is set on the class today, so Modal bills whatever the container actually uses. The 2 GiB row is the working estimate; the others bracket it.
| RAM per container | $/container/mo | Option A | Option B |
|---|---|---|---|
| 1 GiB | $10.00 | $1,750 | $992 |
| 2 GiB β estimate used | $15.75 | $2,613 | $1,418 |
| 4 GiB | $27.26 | $4,339 | $2,272 |
Worst case doubles the bill. Setting an explicit memory= caps that exposure and makes the forecast firm β a bill you cannot bound is not a quote.
Start on Option B at a 10-hour window: $1,418/month. It is identical to Option A during the hours that carry traffic and saves $14,340 a year on hours that carry none.
Move to Option A only if the customer confirms spikes can land outside the window. The difference is $1,195/month for overnight readiness β priced honestly, that is what a 3 a.m. spike costs.
Two things before either number is firm: set an explicit memory= so the bill is bounded, and re-measure the cold start. The 25-second figure in the code predates memory snapshots; if restore is really 3β5 seconds, a much smaller warm pool covers the same spike and both numbers fall.