Multi-Tenant by Default: Hosting Every Site in One Process
The default way to run a web app is one process per app. A Next.js app is a Node process; a Rails app is a Rails process. Each one owns its own runtime, its own memory, its own copy of everything. It works, and it's simple — right up until you're running more than one. Then every app you add is another process to supervise, another heap sitting mostly idle, another fixed cost you pay whether or not anyone is visiting.
This is the part nobody benchmarks, because it doesn't show up in a single-app test. It shows up on the invoice.
bext is built the other way around. Every site this server hosts — dozens of them — renders inside one process, drawing on one shared pool of V8 workers. Adding the next site costs almost nothing. Here's how that works, and where it bites.
The unit of cost is the process, not the request
Spin up ten Next.js apps and you have ten Node processes. Each boots its own V8, resolves its own node_modules, and holds its own JIT-compiled code and heap. Nine of them can be idle at 3am and they're still resident, still counted against your RAM. Memory grows with the number of apps, not the amount of traffic — which is the worst way for a cost to scale, because most apps are idle most of the time.
The insight behind bext's hosting model is that rendering is a shared, fungible resource. Your component tree doesn't care which V8 isolate runs it. So instead of giving every app its own runtime, bext runs one fixed-size pool of V8 evaluation workers — eight by default1 — and every app on the box draws from it.
flowchart TB
subgraph PA["Process per app — cost grows with app count"]
direction LR
pa1["App A: Node + V8"]
pa2["App B: Node + V8"]
pa3["App C: Node + V8"]
end
subgraph BX["bext — one shared pool, fixed size"]
direction LR
bxA["App A"] --> POOL["Shared V8 pool"]
bxB["App B"] --> POOL
bxC["App C"] --> POOL
end
classDef hot fill:#fff1f2,stroke:#f43f5e,color:#0c0c0c,stroke-width:1.5px
class POOL hotA request that needs to render borrows a worker, runs the component tree, and hands the worker back. Ten apps, one pool. A hundred apps, the same pool. Render capacity is paid for once.
| Process per app | bext shared pool | |
|---|---|---|
| Runtime per app | one Node process each | one pool, shared by all |
| Idle cost | grows with app count | fixed |
| The marginal app | another full runtime | a directory and a vhost |
| Render capacity | siloed per app | pooled across the box |
The practical effect: the marginal cost of the next site is roughly a directory and a virtual host, not another runtime. That's the difference between hosting a handful of apps on a big box and hosting dozens on a small one.
How a request finds its tenant
A request arrives, and bext reads the Host header to match it to a site. In auto-detect mode, a virtual host is just a directory:
server {
server_name example.com;
root /srv/sites/example;
}That's the whole configuration. bext looks at the directory, detects what it is — a PRISM app, a PHP app, a static site, or a reverse-proxy target — and serves it in-process. There's no separate app server to start; the same binary that terminates TLS and evaluates WAF rules also renders the page.
Most requests never reach the render pool at all. If the ISR cache holds a fresh entry for that host and path, it's served straight from memory without running any JavaScript. And because the cache and the stampede guard are shared infrastructure too, a traffic spike on one tenant is absorbed before it ever reaches a worker — the pool only ever does the work that genuinely has to run your code.
Adding a site is three calls
Because hosting is process-free, going live is mostly paperwork — and bext does the paperwork. On-host tooling talks to a loopback SDK that provisions a domain end to end:
# 1. Get a certificate — ACME HTTP-01, no certbot, no cron job
curl -s localhost/__bext/sdk/certs/provision \
-H 'X-Bext-App-Id: control' \
-d '{"domain":"example.com"}'
# 2. Point the domain at a directory — TLS wired up, no restart
curl -s localhost/__bext/sdk/vhost/upsert \
-H 'X-Bext-App-Id: control' \
-d '{"domain":"example.com","root":"/srv/sites/example"}'bext serves the ACME challenge for any hostname — even one with no vhost configured yet — so a brand-new domain can complete its first handshake before it's set up. The SNI resolver picks the new certificate up on the next connection, renewal happens on its own before expiry, and there's no reload in the loop. The same code path that serves your first request provisioned the certificate behind it.
What's shared, and what isn't
A shared runtime is only safe if tenants can't see each other, so the line is drawn deliberately.
Shared — the expensive, stateless machinery: the V8 render pool, the ISR cache, TLS termination, the WAF, compression.
Isolated — anything with a tenant's name on it: each app's data lives behind its own application id. The built-in KV, queue, and cache APIs are scoped by that id, and the loopback SDK that on-host code uses to reach them is bound to the same id — so one app physically cannot read another's keys. Compiled bundles, sessions, and secrets are per-app. The pool runs everyone's code; it never hands anyone everyone's data.
"Multi-tenant" here means many trusted apps that you operate, sharing a box — not arbitrary untrusted code. Running untrusted third-party code is a different problem with a different boundary: the plugin sandbox (WASM, QuickJS, nsjail), which isolates by mechanism, not by application id.
The failure mode you inherit
Sharing the pool is the whole point, and it's also the catch. If one render wedges — an infinite loop, a runaway regex, a synchronous call that never returns — it pins a worker. Enough of those and the pool starves, and now it isn't one site that's down, it's all of them. We know because it happened to us.
So the pool is bounded on every axis. A render deadline terminates a render that blows its budget. A circuit breaker quarantines a worker that keeps timing out and routes around it. Fresh workers warm from a prebuilt snapshot, so recycling a bad one is cheap. The goal was never to make a bad render impossible — it's to make sure a bad render stays your problem instead of becoming your neighbor's.
A shared pool trades isolation for density. That's the right trade when you run many apps that each fit comfortably inside one box's capacity. It's the wrong trade when a single app needs to saturate the hardware on its own — at that point, give it a dedicated box and skip the pool entirely.
Is this for you?
If you run one big app, the process-per-app model is fine; you were going to use the whole machine anyway. The shared pool earns its keep when you have many apps — internal tools, client sites, microsites, staging environments — that are each small, mostly idle, and individually not worth a dedicated server. That's exactly the case where per-app processes quietly bleed money, and where a shared pool collapses a fleet of half-used servers down to one.
It's the same idea that runs underneath everything else in bext: do the expensive work once, and share the result.
- Set with
BEXT_V8_POOL_SIZE. The workers are separatebext-server --v8-eval-workersubprocesses rather than threads, so a worker that crashes takes down a worker — not the host that's serving everyone else. ↩