Warm pool: never-used containers against a cold start
Whether a pool of locked-down containers that no code has touched, each claimed once and removed after, takes the
container start off the path of a session's first call (SANDBOX_SESSION_POOL; the terms session, cell
and relay are defined in the sessions
guide (EXTERNAL · source repo ↗)). The page has three parts: the pool as built, the language hints that steer it,
and the probe that came first. The probe was measured 2026-10-02 07:51 PDT on a laptop, docker 29.1.3,
image node:22-alpine, the docker provider's lockdown flags, 20 samples per bar, a
node -e snippet as the call. A laptop number, not a product number: what carries over is the ratio and the
breakdown, not the milliseconds.
Built: the pool with interpreters attached (2026-10-02)
Each never-used member gets an interpreter started and its relay attached
for every language the image runs, and OpenSession hands one over. Measured
2026-10-02 with pooled/main.go against the provider directly (not through
plimsolld), image plimsoll/sandbox-python:latest, 10 samples per bar, 3 s
between samples so the pool refills, a cell of 6 * 7. Without a pool the time is a container start, the
read-back and process list, then the first cell starting the interpreter and its relay.
What made the first measurement slow: OpenSession re-ran the docker Preflight, which re-runs once its
result is 5 seconds old, before claiming. A claim now skips it: the member was verified when it was made and is read
back before every call, and it must match the execution state the last Preflight verified. With that, claiming takes
under a millisecond and the time is the first cell's. The cost is memory: the interpreters a waiting member holds count
inside its session's memory limit, including the language a session never uses.
Language hints (2026-10-02)
A session may name the languages its cells
will use. It is handed the waiting member warming the most of them, and the pool divides its size across language sets
by a weight that follows roughly the last 16 to 32 opens. Measured 2026-10-02 with
hints/main.go against the provider directly, a pool of 4, 3 s between opens.
Every hinted open got a warm member (a cold open is about 740 ms). Its first cell answered in
31 to 53 ms,
above the 18 to 22 ms of the pooled run above; that gap was not investigated. A hint changes latency, never behavior: a cell in a language the member did not warm still runs, starting its
interpreter on first use, and a session without a hint counts as wanting every language. Hints move members between
sets and never add any, so SANDBOX_SESSION_POOL still bounds the memory.
The probe before the build: a plain pool
Time to first output
"Cold run" is today's path: docker run --rm of the snippet. "Pool claim" is docker exec of
the same snippet into a pre-started, never-used member. "Read-back" adds the docker inspect a session
already makes before every call; a pool would make it at claim.
Where a claim's time goes
trueNode starting and printingMost of what remains is the docker exec round trip and Node starting. A warm session cell takes 14 to 15
ms on this laptop because its relay is already attached and its interpreter already running (measured 2026-10-01);
a pool member could be prepared the same way, which is what the pool as built (above) does.
What it costs
- Memory held while idle: about 17 MiB per member under gVisor (its Sentry), under
1 MiB under runc. A pool of 8 under gVisor holds about 137 MiB, which
SANDBOX_TOTAL_MEMORY_MBwould have to count. - Restocking, off the call's path: starting a member takes 133 ms (runc) and 157 ms (gVisor); removing one 98 and 120 ms.
- No clone caveats: every member boots on its own, and none is ever handed to a second call.
All numbers
| Runtime | Measure | Median, pass 2 | Median, pass 3 | p90, pass 2 |
|---|---|---|---|---|
| runc (container) | cold_run | 210 ms | 206 ms | 222 ms |
| runc (container) | claim_exec | 83 ms | 76 ms | 95 ms |
| runc (container) | claim_inspect_exec | 88 ms | 94 ms | 100 ms |
| runc (container) | pool_member_start | 133 ms | 133 ms | 146 ms |
| runc (container) | member_remove | 98 ms | 72 ms | 125 ms |
| runc (container) | idle_member_mib | 0.5 MiB | 0.5 MiB | 0.5 MiB |
| gVisor (kernel tier) | cold_run | 275 ms | 277 ms | 297 ms |
| gVisor (kernel tier) | claim_exec | 81 ms | 82 ms | 84 ms |
| gVisor (kernel tier) | claim_inspect_exec | 91 ms | 100 ms | 98 ms |
| gVisor (kernel tier) | pool_member_start | 157 ms | 147 ms | 169 ms |
| gVisor (kernel tier) | member_remove | 120 ms | 123 ms | 152 ms |
| gVisor (kernel tier) | idle_member_mib | 17.2 MiB | 17.3 MiB | 17.2 MiB |
Pass 1 agreed with these to within 10 ms.
The data and how to rerun it
Raw samples beside this page: pass-2.json and pass-3.json (the
probe), pooled-runc.json and pooled-runsc.json (the pool
as built), hints-runc.json (language hints). exec-split.json
holds two medians per runtime timed by hand (docker exec of true, and of the snippet); no
program reproduces it. The programs are in
measurements/warm-pool
(EXTERNAL · source repo ↗). From a checkout, with the images make docker-images builds (and
gVisor installed for the runsc runs):
python3 measurements/warm-pool/probe.py 20 docs/measurements/warm-pool/pass-2.json
go run ./measurements/warm-pool/pooled # runc
go run ./measurements/warm-pool/pooled -runtime runsc # gVisor
go run ./measurements/warm-pool/hints
python3 measurements/warm-pool/report.py
Your numbers will differ from these: they are one laptop's. What should carry over is the ratio between a pool and a cold start, and where the time goes.