Session latency: what an agent's code tool pays per call

Measured docker, runc (container tier, 15 repetitions, 2026-10-01 22:35 UTC); docker, gVisor (kernel tier, 15 repetitions, 2026-10-01 22:36 UTC); openshell (container tier, 15 repetitions, 2026-10-01 23:02 UTC). Machine: Intel(R) Core(TM) Ultra 9 275HX, 24 CPUs, linux/amd64. Image for plimsoll: plimsoll/sandbox-python:latest. plimsoll's providers were called directly, not through the daemon, whose overhead plus the client's was about 3 to 4 ms a round trip in the TypeScript client's tests. Each timed call followed an untimed pause of 250 ms, because an agent's calls are seconds apart (its model thinks between them) and a docker session finishes its cleanup after answering.

No network is in these numbers: the providers ran beside the program timing them. A daemon hosted away from its caller adds that network round trip to every call. The terms session, cell and relay are defined in the sessions guide (EXTERNAL · source repo ↗); the time a warm pool takes off a session's first call is on the warm-pool page (INTERNAL · measurement →).

What keeping state buys

A call that uses an array of 10 million values (80 MB) an earlier call loaded, against a call that has to build it again:

The more an agent's data costs to load, the larger the difference; building this array takes about 0.1 s of plain compute, and parsing a real file of that size takes longer.

Every operation

docker, runcdocker, gVisoropenshell
fresh snippet docker, runc: 221 ms 221 ms docker, gVisor: 272 ms 272 ms openshell: 554 ms 554 ms fresh Python run docker, runc: 249 ms 249 ms docker, gVisor: 429 ms 429 ms openshell: 595 ms 595 ms session open docker, runc: 228 ms 228 ms docker, gVisor: 260 ms 260 ms openshell: 554 ms 554 ms session snippet call docker, runc: 96 ms 96 ms docker, gVisor: 92 ms 92 ms openshell: 36 ms 36 ms first JavaScript cell docker, runc: 220 ms 220 ms docker, gVisor: 302 ms 302 ms openshell: 106 ms 106 ms warm JavaScript cell docker, runc: 14 ms 14 ms docker, gVisor: 16 ms 16 ms openshell: 6 ms 6 ms first Python cell docker, runc: 215 ms 215 ms docker, gVisor: 372 ms 372 ms openshell: 101 ms 101 ms warm Python cell docker, runc: 14 ms 14 ms docker, gVisor: 15 ms 15 ms openshell: 5 ms 5 ms reload: fresh run per call docker, runc: 352 ms 352 ms docker, gVisor: 714 ms 714 ms openshell: 939 ms 939 ms reload: kept in the interpreter docker, runc: 20 ms 20 ms docker, gVisor: 23 ms 23 ms openshell: 10 ms 10 ms call after a suspend docker, runc: 151 ms 151 ms docker, gVisor: 144 ms 144 ms openshell: 875 ms 875 ms

Medians, with the 10th and 90th percentiles

Operationdocker, runcdocker, gVisoropenshell
fresh snippet
a new sandbox per call: console.log(1)
221 ms
186 to 229
272 ms
246 to 306
554 ms
551 to 608
fresh Python run
a new sandbox per call: project, python3 main.py
249 ms
235 to 426
429 ms
404 to 617
595 ms
545 to 602
session open
create, verify and list a session's sandbox
228 ms
204 to 240
260 ms
233 to 436
554 ms
550 to 556
session snippet call
console.log(1) in an open session (openshell sweeps before answering, docker after)
96 ms
84 to 106
92 ms
88 to 115
36 ms
35 to 63
first JavaScript cell
starts the interpreter (a new session, opened untimed)
220 ms
200 to 235
302 ms
283 to 326
106 ms
104 to 108
warm JavaScript cell
n = (globalThis.n ?? 0) + 1 in a live interpreter
14 ms
13 to 18
16 ms
15 to 18
6 ms
5 to 10
first Python cell
starts the interpreter (a new session, opened untimed)
215 ms
197 to 225
372 ms
369 to 389
101 ms
98 to 117
warm Python cell
n = globals().get('n', 0) + 1 in a live interpreter
14 ms
12 to 15
15 ms
14 to 18
5 ms
4 to 6
reload: fresh run per call
every call builds a 10-million-value array (80 MB) and takes its mean
352 ms
326 to 538
714 ms
700 to 885
939 ms
884 to 1095
reload: kept in the interpreter
the array from an earlier cell: a.mean()
20 ms
18 to 23
23 ms
21 to 25
10 ms
9 to 12
call after a suspend
Suspend, then console.log(1): the resume is in the call
151 ms
134 to 159
144 ms
136 to 164
875 ms
794 to 1014
Where a docker session call's time goes. One docker inspect that reads the container's settings back, then, for a snippet, one docker exec that starts a node process. A cell starts no process: it goes as one line to a relay the session keeps attached beside the interpreter (since 2026-10-01). The sweep that proves no process of the call is left, a second docker exec, runs after the answer has gone back; a call that arrives while it runs waits for it. OpenShell does the same since 2026-10-01: a relay on an exec stream held open, and the sweep after answering.
How to read it. A "fresh" row creates a sandbox for the call and deletes it afterwards. A "session" row is a call in a sandbox kept open; the sweep that kills the call's leftover processes runs after the answer on both providers. A "cell" runs in an interpreter the session keeps alive, so variables survive between calls; the "first" cell includes starting that interpreter. The two "reload" rows are the case keeping state is for: an array of 10 million values built on every call, against the same array kept in the interpreter and only averaged.

The data and how to rerun it

Raw samples beside this page: docker.json, docker-runsc.json, openshell.json. The programs are in measurements/session-latency (EXTERNAL · source repo ↗). From a checkout, with the images make docker-images builds (gVisor installed for the runsc run, and an OpenShell gateway with the SANDBOX_OPENSHELL_* settings in the environment for the openshell run):

go run ./measurements/session-latency -provider docker
go run ./measurements/session-latency -provider docker -runtime runsc
go run ./measurements/session-latency -provider openshell
go run ./measurements/session-latency/report > docs/measurements/session-latency/index.html

Your numbers will differ from these: they are one machine's. What should carry over is the order of the rows and the ratios between them.