← the tour · concept
concept vii
The growth ladder
Scale is a ladder you climb rung by rung, never a rewrite — because feature code is written against seams, not against a deployment.
The blessed baseline is the boring one: a single node — SQLite + systemd. One process, one db file, a unit file, journald for logs. This is a proven shape, not a toy. A single SQLite node handles far more traffic than most teams expect — see Anders Murphy's 100,000 TPS over a billion rows: the unreasonable effectiveness of SQLite. Most products never have a reason to leave this rung.
When the time does come, growth is a sequence of adapter swaps, not a rewrite.
Feature code publishes to a Bus and reads from a Store;
neither primitive knows what rung the app is running on. Climbing the ladder means
swapping the thing behind the seam — the feature that called
bus.publish("todos") on rung 0 is still calling
bus.publish("todos") on rung 2.
| Rung | What you run | What it buys |
|---|---|---|
| 0 · baseline | One Node/Deno process, one SQLite file, systemd | The whole app. Most products never leave here. |
| 1 · durability | + Litestream → object storage | Continuous backup, then read replicas. |
| 2 · multi-node | + a Bus adapter (Redis / NATS / Honker) | Fan-out across machines. Live repaints cross the network. |
| 3 · per-slice edge | Slices shipped to Cloudflare (Queues, R2, DO SQLite) | Edge primitives where they earn it, from one repo. |
Rung 0 — one process, one file
The composition root is twenty lines. Construct the store, bus, and log; migrate each feature; mount routes; wire subscriptions; listen. Nothing more is required to run a real product, and the systemd unit file that keeps it running is four lines long:
# /etc/systemd/system/myapp.service
[Unit]
Description=myapp
After=network.target
[Service]
WorkingDirectory=/srv/myapp
ExecStart=/usr/bin/node main.ts
Environment=NODE_ENV=production
Environment=LOG_LEVEL=info
Restart=always
User=myapp
[Install]
WantedBy=multi-user.target
Put Caddy or nginx in front for TLS and HTTP/2 — a multi-region page holds several
SSE streams per viewer, and HTTP/2's multiplexing is the right lever there. The
important switch in the app is config.dev: in production it's off, errors
stay in the log, clients see opaque 500s, and no dev surface ships a byte.
The point of the ladder isn't that you'll climb it. Staying on rung 0 is a respectable place to run a real business, and nothing you write there is wasted if you don't.
Why deploys are cheap here
Live streams carry no session state — every render is the full current truth from the store. So a deploy is: stop accepting connections, close open streams, restart the process, let Datastar's built-in retry reconnect each client, and the first render catches everyone up. No drain dance, no sticky sessions, no resume tokens. A dropped stream costs one re-render:
// already in your main.ts — SIGINT/SIGTERM → clean exit
gracefulShutdown({ server, store, log })
Rung 1 — durability without a second database
SQLite in WAL mode — the store's default — is what makes "start single-player, stay fast under multiplayer read load" true: readers never block. The fat-morph pattern is read-heavy by construction, which is SQLite's best case. When you want continuous backup and read replicas, you don't need a different database — you need Litestream, which streams the WAL to object storage as a sidecar process. That's the whole rung: one new process alongside the one you already have, and the app code doesn't change a line.
Rung 2 — multi-node via the Bus seam
The Bus is two methods — publish and subscribe.
On rung 0 it's an in-process Map: instant, free, zero infra. When
you outgrow one machine — or when the Health panel shows CPU-heavy job work starving the
event loop and you want a separate worker process — you swap in a cross-process Bus
adapter. The feature code that publishes and subscribes never changes a line:
import { createBus } from "../../belt/bus.ts"
// rung 0 — in-process Map, the zero-dep default
const bus = createBus()
// rung 2 — cross-process adapter; feature code is identical
const bus = createBus({ adapter: redisBusAdapter(redisUrl) })
One honest warning about the move: Node's cluster module and PM2 cluster
mode are not this rung. Clustering breaks orion's core invariants —
a command on worker A publishes to A's in-process bus, but an SSE stream pinned to
worker B never hears it, so its region never repaints. The real rung-2 move is an
external Bus adapter, which you reach for directly. Skip clustering.
Rung 3 — per-slice edge
One repo can be deploy-heterogeneous: the main loop running on a Node or Deno box,
individual slices shipped to Cloudflare where their primitives genuinely earn it. The
boundary is message-based, never shared memory — the main app
enqueue()s to a Queue a consumer Worker drains, reads and writes R2, or
calls a Worker over HTTP. Those are already the async seams orion has, so there's no
sync-store-across-the-wire problem to invent.
Jobs → Cloudflare Queues
The Jobs seam takes a Queues executor. Features that call
jobs.enqueue() don't change a line.
DO SQLite
A slice that wants its live loop at the edge binds the Store to a
Durable Object's synchronous ctx.storage.sql. The seam is the same;
the executor is CF's.
So how does Postgres fit?
Two honest answers, because Postgres meets orion in two different places:
- For background work — cleanly. The Jobs and Workflow seam takes a Postgres executor with no change to feature code. If you already run Postgres, that's where it slots in first — not as a rewrite, as an adapter behind the seam.
- For the render-path store — possible, but a bigger swap. The
Storeseam is five methods, so a Postgres adapter is writable — but Postgres is async, and the render path is deliberately synchronous. Astore.query()returnsT[], notPromise<T[]>. Making it async ripples through every view, model, and command — that's a different framework. The more honest move when you outgrow one SQLite node is usually a Bus adapter (rung 2) or, on Cloudflare, db-per-tenant using DO SQLite. Postgres on the hot path is a documented redesign, not a flag you flip.
The load-bearing constraint
All of this is possible because the seam is strict about one thing: the
Store is synchronous. Views and region models call
todos.all(ctx.store) during render with no await. That
one constraint is what keeps "why didn't it update?" down to a single suspect
(your render), and it's why SQLite is blessed — it's the database that's already
in-process and synchronous on every rung of the ladder.
SQLite in WAL mode, synchronous reads, single-writer group commit — much of this stance stands on Anders Murphy's work: the unreasonable effectiveness of SQLite, and his Hyperlith — the server-rendered, SSE-morph, one-SQLite-node shape that orion carries into a Node toolbelt.