Durability costs a model call 16 to 21 ms.
A model call made directly against the same call inside a Temporal workflow, 200 runs per arm on LM Studio and vLLM, then the same prompt through the whole Continuum platform on LM Studio, span by span.
UPDATED 2026-09-26 · VERSION 0.3
The benchmark
The benchmark runs two tests against the same model call made directly. The first puts a Temporal workflow around the call and nothing else, so what it adds is durability's cost alone. The second sends the same prompt through the whole Continuum platform, which does far more than run the call, and measures what a real request costs. The lead figures come from 200 runs per arm in the runtime comparison, the first test on LM Studio and on vLLM and the second on LM Studio, and every run is kept with its setup. They replace the "3.5% at p99" the site quoted for months with no run behind it.
Like for like
Durability adds 16 to 21 ms to a model call, and its own time at p99 is 25 to 32 ms.
The first test isolates Temporal. Both arms make the same streamed call, with the same prompt, the same model and the same client pipeline, on one machine. They take turns run by run, so any drift in the model's speed lands on both.
- Client → model. The call made directly, timed from the request to the last token.
- Client → Temporal → model. The same call inside a workflow of one activity, with the answer returned as the workflow's result. Timed from starting the workflow to the result in hand. Nothing of Continuum's is in the path: no Gateway, Postgres, Redis, Relay or fetch.
Table view
| Runtime and prompt | p50 | p95 | p99 | Runs |
|---|---|---|---|---|
| LM Studio, short prompt, 88-token answer | 13 ms | 24 ms | 25 ms | 200 |
| vLLM NVFP4, short prompt, 137-token answer | 13 ms | 24 ms | 25 ms | 200 |
| LM Studio, long prompt, 658-token answer | 14 ms | 26 ms | 30 ms | 200 |
| vLLM NVFP4, long prompt, 700-token answer | 14 ms | 24 ms | 26 ms | 200 |
| LM Studio, 4K document, 358-token answer | 16 ms | 28 ms | 32 ms | 200 |
| vLLM NVFP4, 4K document, 257-token answer | 16 ms | 27 ms | 28 ms | 200 |
| Short prompt | Long prompt | 4K document | |
|---|---|---|---|
| The answer, LM Studio / vLLM | 88 / 137 tokens | 658 / 700 tokens | 358 / 257 tokens |
| LM Studio: the call, p50 | 293 ms | 1,911 ms | 1,122 ms |
| LM Studio: Temporal adds, p50 | 17 ms (5.9%) | 19 ms (1.0%) | 19 ms (1.7%) |
| vLLM NVFP4: the call, p50 | 625 ms | 3,072 ms | 1,162 ms |
| vLLM NVFP4: Temporal adds, p50 | 16 ms (2.6%) | 18 ms (0.6%) | 21 ms (1.8%) |
Each arm ran 200 times, on two runtimes and three prompts: LM Studio running the GGUF build on Windows, and vLLM running an NVFP4 build of the same model in WSL2. Durability's time outside the call didn't move with the runtime or the length of the answer: 13 ms at p50 for an 88-token answer, 14 ms for a 658-token one. The 4K document adds 2-3 ms, because the workflow's input carries its 17 KB prompt and Temporal persists it.
The tail is measured outside the model call.
Every run's Temporal history records when the activity started and finished, so the time the client spent outside it is durability's alone: the start, the dispatch to a worker, the result coming back, and the client's call to collect it. The 2 to 4 ms the activity spends around the model call counts with the call, and the model's own spread can't show up in what is left.
At 200 runs the nearest-rank p99 is the 198th value, so no single slow run sets it. Measured this way, durability's p99 is 25 to 32 ms on every runtime and prompt: 30 ms on LM Studio, 1.5% of a 658-token answer, and 26 ms on vLLM, 0.8% of a 700-token one. Measured end to end instead, each durable run against the direct calls' fit, the long prompt's p99 is 116 ms on LM Studio and 416 on vLLM, which is the model's own spread: its direct calls alone ranged over 191 ms and 527. One run in 1,200 spent more than 100 ms outside the call, LM Studio's 4K run 126, whose workflow start took 433 ms, which fits the reconnect stall under Full service.
Temporal's time is three hand-offs, whatever the model writes.
| From Temporal's history | p50 | p95 | p99 |
|---|---|---|---|
| Workflow started to activity started | 5 ms | 15-16 ms | 16-18 ms |
| Activity completed to workflow completed | 5 ms | 5-8 ms | 8-13 ms |
Durability's 13 to 17 ms outside the call at p50 is the start, the dispatch, the result coming back, and about a millisecond for the client's call to collect it. The table has the dispatch and the return as Temporal's history records them; the start and the collect are the client's own calls. Inside the activity it adds 2 to 4 ms beyond the model call, for starting the activity and returning its result.
A long answer doesn't make any of it bigger, which is why on LM Studio its share falls from 5.9% of an 88-token answer to 1.0% of a 658-token one. Eager workflow start would trim the dispatch by about 3 ms, but only for a process that hosts a worker, and a worker has no place in the Gateway.
The first durability runs: 20 and 30 per arm, LM Studio only
Taken the morning of 2026-09-25, before the runtime comparison, with the
inference capability's sampling (temperature 0.7, 4,096 tokens) and
LM Studio at one slot, then four. At 20 or 30 runs a p99 is the slowest run, so these
give the slowest run by name.
| 80 tokens, 1 slot | 730 tokens, 1 slot | 730 tokens, 4 slots | |
|---|---|---|---|
| Runs | 30 | 20 | 20 |
| Client → model, p50 | 289 ms | 2,392 ms | 2,456 ms |
| Client → Temporal → model, p50 | 311 ms | 2,407 ms | 2,474 ms |
| Temporal adds, p50 | 21 ms (7.3%) | 14 ms (0.6%) | 15 ms (0.6%) |
| Outside the activity, p50 | 15 ms | 16 ms | 19 ms |
| Outside the activity, p95 | 25 ms | 43 ms | 51 ms |
| Outside the activity, slowest run | 35 ms (12.1%) | 55 ms (2.3%) | 52 ms (2.1%) |
Full service
Through the whole platform, a request costs 35 to 46 ms more.
The second test sends the same prompt through everything the
durable inference
page describes: the Gateway's auth, session and writes to Postgres and Redis, the
workflow, the answer streamed into Redis as the model writes it, the Relay's notice over
SignalR, and the fetch. It ran on LM Studio, 200 runs per arm, because the platform is
wired to LM Studio, and at the inference capability's own sampling
(temperature 0.7, 4,096 tokens), which is why its answers run 80, 728 and 328 tokens.
| Short prompt, 80-token answer | Long prompt, 728-token answer | 4K document, 328-token answer | |
|---|---|---|---|
| The call, p50 | 301 ms | 2,502 ms | 1,015 ms |
| The platform adds, p50 | 35 ms (11.5%) | 46 ms (1.9%) | 37 ms (3.6%) |
The 202 Accepted, p50 | 12 ms | 12 ms | 13 ms |
Table view
| Prompt | p50 | p95 | p99 | Runs |
|---|---|---|---|---|
| Short prompt, 80-token answer | 25 ms | 36 ms | 40 ms | 200 |
| Long prompt, 728-token answer | 28 ms | 43 ms | 344 ms | 200 |
| 4K document, 328-token answer | 25 ms | 35 ms | 38 ms | 200 |
The model call dwarfs everything around it.
Every host exports its spans to the Aspire dashboard, and one trace holds the Gateway's handler, the worker's workflow and activities, and the Relay's handler together. Drawn to scale, each stretch at its own p50, Continuum is two slivers around the model call. The medians sum to 33 ms; measured end to end against the direct call, the platform adds 35 ms on the short prompt and 46 on the long one. The short and long runs' spans agree to within a millisecond everywhere but the call.
- Gateway11.6 ms
- dispatch5.0 ms
- read input1.4 ms
- model call301 ms
- end markers5.4 ms
- notice5.9 ms
- Relay0.9 ms
- fetch3.0 ms
Continuum 33 ms of 335, both ends together. Temporal is 14 of them: the start, 2.8; the dispatch, 5.0; the workflow task before the notice, 5.9.
- Gateway11.6 ms
- dispatch5.0 ms
- read input1.4 ms
- model call2,502 ms
- end markers5.4 ms
- notice5.9 ms
- Relay0.9 ms
- fetch3.0 ms
Continuum 33 ms of 2,535, too thin to point at.
What each stretch is
| Stretch | p50 | What happens |
|---|---|---|
Gateway, to 202 | 11.6 ms | Auth and session from the Redis cache; the thread and both messages in Postgres, with the prompt going into Redis alongside; StartWorkflow, 2.8 ms |
| Dispatch | 5.0 ms | Temporal's workflow task, with the activity handed over in its response |
| Read input | 1.4 ms | Three Redis calls in sequence: check the prompt exists, clear a retry's output, read the prompt |
| The model call | 301.4 ms | The same as calling the model directly; 2.1 ms of it before the response headers |
| End markers | 5.4 ms | The last chunk and the end markers to Redis |
| Notice | 5.9 ms | Temporal: the activity's completion, a workflow task, then the notice as a local activity |
| Relay | 0.9 ms | The Relay's handler, then SignalR to the client |
| Fetch | 3 ms | The output from the Gateway, on the client's clock |
Against durability alone, the platform adds the Gateway's auth, session and writes
(about 12 ms, of which StartWorkflow's 2.8 is durability's own), the notice
as a Temporal local activity and the Relay (about 7), and the fetch (about 3). The 5.9 ms
before the notice is kept on purpose. Moved inside the activity, the notice would go out
sooner but best-effort; as a local activity, Temporal retries it if it fails.
Three levers are still open, with the saving the spans predict: the thread and both messages in one statement (about 4 ms), the end-of-stream Redis calls in one batch (about 4), and a single read before the model call (about 1).
The platform's slowest runs are Temporal calls that stall about every five minutes.
Twelve of the 600 platform requests ran more than 100 ms over their median, and every one
was a Temporal RPC taking 317 to 447 ms: the Gateway's StartWorkflow, the
worker's dispatch, or the activity's completion. Each of those traces has a normal model
span. They come about every five minutes per connection, which is when Temporal's frontend
closes a client connection (frontend., 5 minutes
± 10%). On the long prompt they set the p99 outside the activity, 344 ms. The
short prompt and the 4K document held two each, and nearest rank leaves both past the
p99.
The suspected cause is localhost, as in the two-second stall under Fine
tuning. Every host reaches Temporal at localhost:7233, and the Temporal
SDK's gRPC runs in its Rust core, which the .NET setting under Fine tuning doesn't reach.
For a new connection it tries IPv6 first and waits 300 ms before IPv4, which fits the
317-335 ms stalls; the completions' 417-447 ms fit less well.
The Lightning runs of 2026-09-26 made their durable calls with Temporal pinned to
127.0.0.1:7233. The same stall came back at 113 to 129 ms, every time in the
workflow's start: 6 of their 1,400 durable runs spent 102 to 113 ms starting the workflow,
against a median of 3. All six were on the long prompts, the only runs that lasted past
five minutes. That fits the pin removing the 300 ms IPv6 wait and leaving the reconnection
itself, and the platform's 317 to 447 ms above is the cost without the pin.
The first platform runs: 20 and 30 per arm, LM Studio only
| Run | The call, p50 | The platform adds, p50 | Outside the activity, p50 | p95 | Slowest run |
|---|---|---|---|---|---|
| 80 tokens, 1 slot, 30 runs | 278 ms | 38 ms (13.6%) | 26 ms | 37 ms | 38 ms |
| 80 tokens, 4 slots, 20 runs | 302 ms | 36 ms (12.0%) | 27 ms | 34 ms | 35 ms |
| 80 tokens, 4 slots, 30 runs | 302 ms | 33 ms (11.0%) | 25 ms | 34 ms | 35 ms |
| 730 tokens, 1 slot, 20 runs | 2,391 ms | 37 ms (1.5%) | 29 ms | 37 ms | 40 ms |
| 730 tokens, 4 slots, 20 runs | 2,466 ms | 16 ms (0.7%) | 37 ms | 342 ms | 349 ms |
The last run's tail is two of those reconnect stalls, back to back. One more long run
is left out: from its ninth run on, every other direct call waited two seconds for a
new connection, the localhost stall under Fine tuning.
Fine tuning
Measured one change at a time, the platform went from 168 ms to 38.
The first run through the platform added 168 ms to the short answer. Each change after it was measured on its own before the next one went in.
| Change | Added to the short answer, p50 | What moved |
|---|---|---|
| As first built | 168 ms (64.7%) | The 202 in 36 ms, and 50 ms on every task-queue hand-off |
| Eager activity execution | 133 ms (49.0%) | One hand-off fewer per activity; the p95 from 398 ms to 160 |
| One writer coalescing the reply | 133 ms (48.7%) | The long answer's fetch, from 46 ms to 5: one stream entry per 50 ms instead of one per token |
| The Gateway's writes issued together | 138 ms (50.9%) | The 202, from 33 ms to 29 |
| Temporal's dev server tuned | 38 ms (13.6%) | The 50 ms hand-offs gone: one remote activity from 200 ms to 11 |
| Four slots and the settings cache fixed, together | 36 ms (12.0%) | One Postgres read fewer per request |
On the dev server as it ships, every task-queue hand-off costs 50 ms.
Timing trivial workflows, with no Continuum code in them, found why the early runs were slow. The Aspire AppHost now starts Temporal with four settings, each measured on its own, and every run in Like for like and Full service ran on it.
- Eager activity execution
(
system.). The worker receives the activity in the response to the workflow task that scheduled it, one hand-off fewer. It is off by default on the dev server.enableActivityEagerExecution - SQLite in WAL mode (
journal_mode=wal,synchronous=normal). The default journal synced on every commit, 25-50 ms a hand-off. WAL atnormalsurvives a process crash and can lose the last transactions on a power loss, a trade made for a dev server only. - The transfer queue's poll rate
(
history.). The 50 ms was the history service's reader moving new tasks to matching, limited by default to 20 polls a second. At 1,000, one remote activity fell from 100 ms to 11.transferProcessorMaxPollRPS= 1000 - One task-queue partition
(
matching.andnumTaskqueueReadPartitions …WritePartitions, both 1). With the 50 ms gone, 1 run in 100 stalled for two seconds on a partition no poller was covering. The p99 went from 2,010 ms to 26. One partition suits one worker, and caps a queue's throughput across many.
These settings tune a single-node dev server. A production cluster persists to Postgres or Cassandra and runs partitions for throughput across many workers, so a cluster gets measured on its own.
localhost cost two seconds a connection.
On this machine every store listens on 127.0.0.1 only, and LM Studio on
IPv4 only. A new .NET connection to localhost tries IPv6 first, nothing
answers on ::1, and Windows takes about two seconds to give up and fall
back. The
connection pools hid it, since they pay it only when a connection opens. LM Studio drops
a connection after about four idle seconds, so every model request after a quiet spell
paid two extra seconds, and a Postgres connection opened cold took 4.1 seconds.
Every connection to the stores and the model now uses 127.0.0.1, and the
AppHost sets DOTNET_SYSTEM_NET_DISABLEIPV6=1 on every project it starts.
Temporal's client connects from the SDK's Rust core, which that setting doesn't reach, so
it still dials localhost (see Full service). That is for the
dev stack only: on an IPv6-only network it would break name resolution, and a deployment
configures its own endpoints.
By the book
Every run is taken the same way, and can be repeated from its setup.
- Interleaved. Warmups first, then the two arms alternate, one request in flight on the whole machine, so drift in the model's speed lands on both.
- Equal output. Each run is compared with a direct call of the same length, from a Theil-Sen fit across the direct runs, so one slow run can't move it. At the fixed seed each runtime repeats one answer, so the two arms are compared at identical output.
- Checked before it starts. A preflight confirms the loaded model and its slot count match the configuration, and every answer is checked against the configured model.
- The tail outside the model. Each run's time outside the activity, from its Temporal history, is the tail measure, and a p99 is quoted only from 200 runs or more.
- Spans. Every host exports its OpenTelemetry spans to the Aspire dashboard, whose telemetry API returns one trace per request.
| Machine | AMD Ryzen 9 9950X3D (16 cores), 64 GB, RTX 5090 (driver 617.14); Windows 11 Pro, Docker Desktop on WSL2 |
| LM Studio | 0.4.25 (llama.cpp 2.45.0): nvidia-nemotron-3-nano-4b, Unsloth GGUF Q4_K_S, 16,384 context, one slot, flash attention, seed 42 |
| vLLM | 0.29.0 in WSL2: an NVFP4 build of the same model, one sequence, 16,384 context, KV cache bf16, Mamba state f32, seed 42 |
| Sampling | Temperature 0.6, top-p 0.95, top-k 20, min-p 0, repeat penalty 1.0, 8,192 max tokens, thinking on. The platform runs use the inference capability's own: temperature 0.7, 4,096 tokens |
| Temporal | The dev server, Server 1.29.0, pinned by image digest, restarted fresh, with the four settings under Fine tuning |
| Platform | Every host a Release build under the Aspire AppHost, the stores in their containers |
| Prompts | Short: "Count from 1 to 20, separated by spaces. Nothing else." Long: "Explain, in about 500 words, how Redis Streams differ from Redis pub/sub, and when to use each." The 4K document: a fixed 4,089-token text with a two-sentence answer |
| Runs | 200 per arm after 10 warmups, per runtime and prompt |
# the model, as the study loaded it: one slot
dotnet run -c Release --project src/Apps/Continuum.Console -- models up --model nvidia-nemotron-3-nano-4b --parallel 1
# like for like: the call directly, then inside a workflow of one activity
dotnet run -c Release --project src/Apps/Continuum.Console -- benchmark durable-inference --runs 200 --warmup 10 --sampling "temperature=0.6,top_p=0.95,top_k=20,min_p=0,repeat_penalty=1,seed=42,max_output_tokens=8192" --prompt "Explain, in about 500 words, how Redis Streams differ from Redis pub/sub, and when to use each."
# the same against vLLM in WSL2
dotnet run -c Release --project src/Apps/Continuum.Console -- benchmark durable-inference --runs 200 --warmup 10 --sampling "…" --prompt "…" --endpoint http://127.0.0.1:8000 --model nvidia-nemotron-3-nano-4b-unsloth-nvfp4 --runtime vllm
# the whole platform, span by span
dotnet run -c Release --project src/Apps/Continuum.Console -- benchmark inference --runs 200 --warmup 10 --headless
# Temporal's own floor: trivial workflows, no Continuum code
dotnet run -c Release --project src/Apps/Continuum.Console -- benchmark temporal-floor --runs 50A number compares only with one taken on the same machine, model and server settings. Docker Desktop's VM sits between the hosts and the stores here, and a Linux host wouldn't have it. A percentage depends on the answer's length: the same 16 ms is a smaller share of a slower answer and a larger share of a faster one. The runtime comparison's own setup, including how the NVFP4 build was made, is on its page.