← 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.

RungWhat you runWhat it buys
0 · baselineOne Node/Deno process, one SQLite file, systemdThe whole app. Most products never leave here.
1 · durability+ Litestream → object storageContinuous 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 edgeSlices 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.

background work

Jobs → Cloudflare Queues

The Jobs seam takes a Queues executor. Features that call jobs.enqueue() don't change a line.

a slice's live loop

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:

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.

orion ✦ a belt of stars · built on datastar