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.

runc (container), JavaScript
22 ms
claim + first cell, against 740 ms without a pool
gVisor (kernel tier), JavaScript
51 ms
claim + first cell, against 817 ms without a pool
runc (container), a waiting member
39 MiB
both interpreters running, against 0.5 MiB for a bare session container
gVisor (kernel tier), a waiting member
66 MiB
both interpreters running, against 18.2 MiB for a bare session container
No pool (open creates the container)Pool (open claims a waiting member)
runc (container): open + first cell0 ms250 ms500 ms750 ms1000 msNo pool, JavaScriptNo pool, JavaScript, runc (container): median 740 ms, range 582 to 817 ms (n=10)740 msPool, JavaScriptPool, JavaScript, runc (container): median 22 ms, range 21 to 25 ms (n=10)22 msNo pool, PythonNo pool, Python, runc (container): median 789 ms, range 584 to 848 ms (n=10)789 msPool, PythonPool, Python, runc (container): median 18 ms, range 16 to 19 ms (n=10)18 msgVisor (kernel tier): open + first cell0 ms250 ms500 ms750 ms1000 msNo pool, JavaScriptNo pool, JavaScript, gVisor (kernel tier): median 817 ms, range 644 to 901 ms (n=10)817 msPool, JavaScriptPool, JavaScript, gVisor (kernel tier): median 51 ms, range 47 to 55 ms (n=10)51 msNo pool, PythonNo pool, Python, gVisor (kernel tier): median 891 ms, range 751 to 1008 ms (n=10)891 msPool, PythonPool, Python, gVisor (kernel tier): median 32 ms, range 29 to 36 ms (n=10)32 ms

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.

A waiting member warming JavaScript only
18 MiB
runc, docker stats
A waiting member warming both languages
38 MiB
runc, docker stats
A waiting member warming Python only
21 MiB
runc, docker stats
Python-hinted open + first cell
35 ms
median of 16, while the split moved
JavaScript-hinted open + first cell
40 ms
median of 16, while the split moved
both languagesPython onlyJavaScript only
Waiting members by the languages they warmstart, no hints yetstart, no hints yet: 4 warming both languages4after 16 Python-hinted opensafter 16 Python-hinted opens: 1 warming both languages1after 16 Python-hinted opens: 3 warming Python only3after 16 JavaScript-hinted opensafter 16 JavaScript-hinted opens: 1 warming Python only1after 16 JavaScript-hinted opens: 3 warming JavaScript only3

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

122 ms
saved per call under runc (210 to 88 ms, read-back included)
184 ms
saved per call under gVisor (275 to 91 ms)
17 MiB
held by each idle pool member under gVisor; 0.5 MiB under runc
~15 ms
what a claim could reach with a relay and interpreter already attached, as a warm session cell does (inferred, not measured here)

Time to first output

Cold run todayPool claimPool claim + read-back
runc (container)0 ms100 ms200 ms300 msCold run todayCold run today, runc (container): median 210 ms, p90 222 ms, range 195 to 234 ms (n=20)210 msPool claimPool claim, runc (container): median 83 ms, p90 95 ms, range 69 to 104 ms (n=10)83 msPool claim + read-backPool claim + read-back, runc (container): median 88 ms, p90 100 ms, range 76 to 100 ms (n=10)88 msgVisor (kernel tier)0 ms100 ms200 ms300 msCold run todayCold run today, gVisor (kernel tier): median 275 ms, p90 297 ms, range 241 to 311 ms (n=20)275 msPool claimPool claim, gVisor (kernel tier): median 81 ms, p90 84 ms, range 77 to 89 ms (n=10)81 msPool claim + read-backPool claim + read-back, gVisor (kernel tier): median 91 ms, p90 98 ms, range 85 to 112 ms (n=10)91 ms

"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

docker exec of trueNode starting and printing
Where a claim spends its time (median, ms)0255075100runc (container)runc (container): docker exec 51 ms51runc (container): Node starting 32 ms32gVisor (kernel tier)gVisor (kernel tier): docker exec 27 ms27gVisor (kernel tier): Node starting 48 ms48

Most 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

All numbers

RuntimeMeasureMedian, pass 2Median, pass 3p90, pass 2
runc (container)cold_run210 ms206 ms222 ms
runc (container)claim_exec83 ms76 ms95 ms
runc (container)claim_inspect_exec88 ms94 ms100 ms
runc (container)pool_member_start133 ms133 ms146 ms
runc (container)member_remove98 ms72 ms125 ms
runc (container)idle_member_mib0.5 MiB0.5 MiB0.5 MiB
gVisor (kernel tier)cold_run275 ms277 ms297 ms
gVisor (kernel tier)claim_exec81 ms82 ms84 ms
gVisor (kernel tier)claim_inspect_exec91 ms100 ms98 ms
gVisor (kernel tier)pool_member_start157 ms147 ms169 ms
gVisor (kernel tier)member_remove120 ms123 ms152 ms
gVisor (kernel tier)idle_member_mib17.2 MiB17.3 MiB17.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.