Scale to zero

Dormancy — stop an idle app and bring it back on a request or a due cron/workflow, with no Kubernetes.

Dormancy lets an idle app scale to zero — the app process stops while nothing is happening and comes back automatically on the next request, or when a scheduled job or a sleeping workflow is due. Idle apps cost almost nothing.

It is the same runtime as always; an always-on wake-orchestrator sits in front and manages the app's lifecycle. Nothing about your queries, mutations, routes, schedules, or workflows changes.

Turn it on

Two pieces: opt the app into sleep mode, then run the orchestrator in front of it.

// apps/api/app.config.ts
export default {
  type: 'api' as const,
  name: 'myApi',
  store: 'postgres' as const,
  dormancy: 'sleep',   // 'always-on' (default) | 'sleep'
}

In dormancy: 'sleep', self-scheduled crons and durable-workflow waits register a row in the _voltro_wakeups table instead of holding an in-process timer — so the orchestrator knows exactly when the app must be awake.

# Single-node, no Kubernetes — requires a SQL store (postgres/mysql/mariadb/mssql)
voltro dormancy
voltro dormancy --port 4000 --app-port 4001 --idle-grace-ms 60000

voltro dormancy owns the public port, spawns voltro serve on an internal port, and proxies to it. It wakes the app on the first request / WebSocket upgrade or when a _voltro_wakeups row falls due, and stops it once it has been idle — no clients, no in-flight requests, no actively-running workflow, no imminent wakeup — for the grace window.

A reconnecting reactive client transparently wakes the app: the WebSocket upgrade brings it up, then the client re-subscribes from its cursor.

The orchestrator and the app share the app's database (the _voltro_wakeups table), so dormancy needs a SQL store — an in-process memory store can't be shared across the two processes.

What keeps an app awake

The app is only stopped when, continuously for the grace window:

  • no WebSocket/reactive clients are connected,
  • no HTTP request is in flight,
  • no workflow is actively executing — a suspended/sleeping workflow is durable in the database and resumes on wake, so it does not keep the app awake,
  • no wakeup is due within the lookahead horizon.

A long-sleeping workflow (sleep('3 days')) is exactly the case dormancy is built for: the app stops, and the orchestrator brings a fresh process up when the sleep is due — the workflow resumes from its journal.

Diagnosing cold-start latency

The flip side of scale-to-zero is a cold start on the first hit after an idle period. Every voltro serve boot logs how long it took to become ready:

serve: ready in 1910ms

To see WHERE those milliseconds go, set VOLTRO_BOOT_TIMING=1 — the same line gains a per-phase breakdown:

serve: ready in 1910ms  { bootMs: 1910, phases: { modules: 1872, config: 0, discover: 18, store: 9, plugins: 1, workflow: 1, ready: 9 } }

The phases, in boot order:

Phase What it covers
modules node init + loading and compiling the JS module graph (the framework + your app). On a scale-to-zero container this is almost always the dominant phase — it is the cost of evaluating the dependency graph on a cold process, and it scales inversely with your CPU allotment. Precompiling the app (voltro build) collapses this phase — see Precompiled boot below.
config loading app.config.ts + the typed-env gate
discover walking the app + loading discovered primitives (plus the precompiled bundle, when voltro build produced one)
store opening the data store + cache/kv facades — a real SQL dialect includes the connection handshake here
plugins plugin bind + lifecycle activation
workflow the durable-workflow engine
ready serveApi's own setup — handler layers, subscribers, reactions, aggregates, the HTTP listener

Read this from the container's own logs at its real CPU allotment, not a beefy dev machine: a modules phase that dwarfs everything else means the cold start is dominated by evaluating the dependency graph (raise the container's CPU, or keep one instance warm); a fat store phase points at the database connection instead. VOLTRO_BOOT_TIMING adds a handful of performance.now() calls and one log line — it is safe to leave on in production.

A web app (voltro start) reports its own phases under the same flag, on its ready line:

boot phases (ready in 640ms)  { bootMs: 640, phases: { modules: 590, config: 6, scan: 12, provider: 22, routes: 4, cdc: 0, ready: 6 } }
Phase What it covers
modules node init + module-graph load/compile — the dominant cold-start phase, same as the api. Note the web start path has no serve-bundle equivalent yet, so voltro build does not collapse this phase for a web app the way it does for voltro serve; it is the framework graph evaluated unbundled on every cold process
config app.config.ts + the typed-env gate
scan reading the built shell, resolving the api origin, the ISR cache backend, and walking src/pages
provider importing the precompiled SSR bundle (dist/server/ssrEntry.js). Stays small because page modules load lazily — a fat provider phase means the SSR bundle regressed to eager imports
routes building per-route metadata. Cheap from the build-time pageMeta manifest; fat only when it fell back to importing every page module
cdc postgres LISTEN wiring for ISR cache invalidation (zero without it)
ready the HTTP listener flip

What boot timing cannot see: performance.now() starts at process start, so everything before node runs — the container's scale-from-zero scheduling and image pull — is invisible to it. If a request that took many seconds end-to-end shows a ready in well under a second, the tail is infrastructure (image size, cold scheduling), not framework boot — split it with your platform's own request/container timestamps, not with more marks.

Precompiled boot

voltro build precompiles the whole serve path — the framework, Effect, and your app — into a single serve bundle, and voltro serve boots from it directly. Instead of resolving and compiling the full module graph on every cold process, the boot loads one prebuilt file with your app modules as lazy chunks. This collapses the modules phase — serve: ready drops from ~1000 ms to ~180 ms; the win is larger on a scale-to-zero container with a cold filesystem, where per-module resolution costs the most. It applies whether your app uses a SQL driver or the memory store: your declared driver (e.g. @voltro/sql-postgres) is inlined into the bundle, and only its native binding (pg) stays external.

voltro build ./apps/api    # produces .framework/dist-api/serveBundle/
voltro serve ./apps/api    # boots from the bundle automatically

Building an API app produces the bundle, and voltro serve boots from it. In production (NODE_ENV=production) the bundle is required — you run voltro build before voltro serve, and a bundle-build failure is fatal: production never transpiles on demand, so it fails loud rather than silently falling back to the slow tsx path. (voltro dev and a non-production local voltro serve still fall back to tsx as a convenience.) The generated Dockerfiles already do this: voltro build at build time, voltro serve at start.

Because production never transpiles, the serve image needs none of the build toolchain. The framework declares tsx, esbuild, vite, and Tailwind as optional dependencies of @voltro/cli, and the production Dockerfiles isolate the app with pnpm --prod --no-optional deploy — which drops that whole tree (and its native binaries) from the image. A serve image ships only what it runs at runtime: your app, the framework, and the one SQL driver you declared.

And it shrinks further, the same way a web image does. The serve bundle is directly executable, so the production API container runs it with node .framework/dist-api/serveBundle/serveEntry.js — not pnpm voltro serve — with no pnpm process and no @voltro/cli bin to resolve at boot. That lets voltro prune-runtime drop @voltro/cli and the whole inlined framework tree from the runtime image too; what stays is the native SQL driver your app actually declared (traced + kept automatically) — a memory-store api's node_modules fell from ~146 MB to ~11 MB in a fixture. The serve entry chdir's to the app root before its app-module registry keys are computed, so the pruned, relocated tree still resolves every module.

Web apps get the same treatment. voltro build also precompiles the voltro start runtime into a start bundle (.framework/dist-web/startBundle/startEntry.js, framework inlined) — so a cold web boot loads one artefact instead of resolving the whole framework graph. This collapses the web modules phase the same way the serve bundle does (~17× on a small app in practice), which matters most exactly where it hurts: a scale-from-zero container on a fraction of a vCPU. In production the container runs that bundle directlynode .framework/dist-web/startBundle/startEntry.js, not pnpm voltro start — so there is no pnpm process and no @voltro/cli bin to resolve at boot; the bundle is a self-contained, relocation-safe entrypoint (its main-guard chdir's to the app root so cwd-based resolution holds anywhere). In development voltro start imports the same bundle; if it ever fails to build or load, that path falls back to the ordinary per-module boot — slower, never broken.

And the image itself shrinks — to almost nothing. Because the SSR bundle, the start bundle, and the precompiled config all inline + tree-shake the framework, and the production entrypoint is the bundle itself rather than the CLI, a booted web app needs from node_modules only the runtime-external native leaves it actually reaches (a SQL driver an ISR or config path touches) — everything else is already compiled into the bundles. voltro prune-runtime traces the real reachable set from that entrypoint (@vercel/nft, the same tool behind Next.js output: standalone) and drops the rest — including @voltro/cli and the whole inlined effect/React tree, which nothing at runtime imports any more. It's automatic — a native driver you use is traced and kept, one you don't is dropped, no per-app allow-list — and for a static/SSR marketing site with no native runtime dependency, node_modules collapses to zero (measured on a fixture: 151 MB → ~0 B): the image is just the node base plus .framework. A build-time boot smoke then starts the pruned tree with the real node …/startEntry.js entrypoint and fails the build unless it reaches start: ready, so a slimmed image that can't boot never ships. A smaller image is a faster cold pull on a fresh node — the other half of the scale-from-zero latency the boot bundle can't touch.

Tiers (Voltro Cloud — coming soon)

Managed cloud hosting is not yet available; the Pro / Enterprise rows below are the planned managed tiers. Today you self-host and run voltro dormancy yourself.

Tier Behavior
Free / Self-Host (today) aggressive sleep — voltro dormancy, honest cold-start on first hit per idle period; idle ≈ $0
Pro (coming soon) keep-warm — sub-second wake via the managed warm-pool/snapshot adapter
Enterprise (coming soon) always-on / dedicated — never scales down

On Voltro Cloud (when it ships) the wake adapter and grace values will be chosen for you per tier; on self-host you run voltro dormancy and tune the knobs yourself. The runtime is identical in every case.