Running It
Making it scale, and keeping it maintainable
What the platform can actually serve today, measured under load — then the arithmetic from requests to readers, where it genuinely breaks first, and the trigger-gated path to millions.
Suggest an editMaking it scale, and keeping it maintainable
You'll be able to: turn a throughput measurement into a defensible user-capacity number; tell which component a load test actually saturated; and stage a scaling plan by trigger rather than by ambition.
What it can serve today, measured
Assertions about headroom are cheap, so here is the platform under real load. Measured 2026-07-18 against the production deployment, one replica, driven from another node inside the cluster so the CDN and the home uplink are out of the picture — this is the origin's own ceiling.
| Endpoint | Response size | Peak req/s | p50 | p99 | App CPU |
|---|---|---|---|---|---|
/api/health |
~50 B | 15,440 | 12 ms | 46 ms | 0.74 cores |
/api/synapse/index |
~33 KB | 1,690 | 102 ms | 583 ms | 0.67 cores |
| a lesson read | ~9 KB | 1,453 | 29 ms | 78 ms | — |
Memory under sustained load: ~36 MiB, against a 256 MiB limit. The application never came close to its memory cap, and never used one of the node's eight cores.
The saturation curve
Throughput is only meaningful next to the latency it costs. Driving /api/synapse/index harder:
| Connections | req/s | p50 | p99 |
|---|---|---|---|
| 50 | 1,317 | 34 ms | 98 ms |
| 100 | 1,540 | 60 ms | 118 ms |
| 200 | 1,690 | 102 ms | 583 ms |
| 400 | 1,433 | 243 ms | 983 ms + timeouts |
The knee is around 200 connections. Past it, throughput falls while latency climbs — the classic signature of a saturated system spending its time on queueing rather than work. 1,690 req/s is the number; anything beyond it is worse in both dimensions.
⚠️ My first two runs measured the wrong thing, and both looked authoritative.
The first used 50 connections against a 34 ms endpoint. 50 ÷ 0.034 ≈ 1,470 req/s — so the "result" of 1,317 was arithmetic about my load generator, not a property of the server. A closed-loop load test with too few connections measures its own concurrency limit.
The second was subtler. At 1,690 req/s with 33 KB responses the server was pushing ~56 MB/s, which is roughly what the WireGuard mesh between two home machines will carry. So the content ceiling was bandwidth, not the application. The tell was that CPU sat at 0.67 of 8 cores while throughput refused to rise.
The /api/health row is the control that settles it: same server, tiny response,
15,440 req/s on 0.74 cores. The application is roughly ten times faster than the pipe it is
speaking through.
From requests per second to readers
A req/s figure is not a capacity answer until it is joined to a model of what a reader does. Mine, stated so you can substitute your own:
- An actively reading person issues about 1 content request every 30 seconds — navigating to a lesson, opening a section. Assets are immutable-cached, so they are free after first load.
- A reader has about 4 sessions a month, roughly 20 content requests each.
- Traffic is not uniform, so peak ≈ 10× average.
Concurrent readers
At 1,690 req/s, with every single request missing the cache:
1,690 req/s ÷ (1 request / 30 s per reader) ≈ 50,000 concurrent active readersMonthly readers
1,000,000 monthly readers × 4 sessions × 20 requests = 80,000,000 requests/month
80,000,000 ÷ 2,592,000 seconds ≈ 31 req/s average
× 10 peak factor ≈ 310 req/s peakSo one million monthly readers is about 310 req/s at peak — against a measured origin ceiling of 1,690 req/s with the cache switched off entirely. That is roughly 5.5 million monthly readers on a single replica with no CDN at all.
Turn the cache back on and the read path stops being a question. At the ~99% cache-eligibility this content has, the origin sees about 3 req/s per million monthly readers, and the arithmetic runs off into numbers not worth printing.
💡 The interesting output of this arithmetic is not the big number — it is that the read path was never the thing to worry about. Time spent making it faster would have bought nothing. The constraints that actually bind are availability, the judge, and the pipe out of the house.
Where the cache hit rate starts to matter
The origin's egress rides a domestic uplink, so a falling hit rate is felt as bandwidth long before it is felt as CPU. At one million monthly readers and 33 KB responses:
| Cache hit rate | Origin egress at peak |
|---|---|
| 99% | ~0.8 Mbps |
| 90% | ~8 Mbps |
| 50% | ~41 Mbps |
| 0% | ~82 Mbps |
Which is why "cache hit rate falling" is a trigger below, and why it is a more urgent signal than CPU on this deployment specifically.
The judge is the real ceiling
Reads scale by not being answered. Runs cannot be cached — they execute untrusted code — so this is where CPU genuinely goes.
Measured through the production sandbox, a small Python program reports:
{"status":"Accepted","stdout":"4950\n","timeSeconds":0.012044,"memoryKb":3584}12 ms of CPU and 3.5 MB per trivial run. The adapter caps in-flight runs at a semaphore of 8, deliberately, so the sandbox cannot be swamped:
| Mean run duration | Sustained runs/s (8 in flight) |
|---|---|
| 12 ms (trivial) | ~660 |
| 200 ms (realistic mix) | ~40 |
| 1 s (heavy or timing out) | 8 |
At a realistic 200 ms mean, ~40 runs/s. If runs are ~1% of traffic, that supports roughly 4,000 req/s overall — about 13 million monthly readers on the same model as above.
The semaphore is a chosen limit, not a hardware one: the sandbox node has 18 cores, so raising it is a config change once there is evidence to justify it. The right time to raise it is when queueing delay appears, not before — the cap exists so that a burst degrades into waiting rather than into thrashing.
What breaks first, in order
Given the above, an honest ranking of what actually limits this platform today:
| # | Constraint | Evidence |
|---|---|---|
| 1 | Availability — one database, one node | 92 minutes of downtime, measured, today |
| 2 | Single replica — required by per-process rate-limiter state | a restart is a total outage |
| 3 | Judge concurrency — semaphore of 8 | ~40 runs/s at a realistic mix |
| 4 | Home uplink — if the cache hit rate falls | 41 Mbps at 50% miss, 1M readers |
| 5 | Origin CPU | 0.74 of 8 cores at 15,000 req/s |
Origin CPU — the thing most capacity planning starts with — is last, by a wide margin. Everything above it is a structural or operational limit, not a throughput one.
The staged plan, gated by triggers
Each stage names the observation that justifies the work. Until the trigger fires, the work is speculative — and given the numbers above, most of it is a long way off.
Today → tens of thousands of monthly readers
No capacity work. The measured headroom covers this by two orders of magnitude. The work that is justified is availability, which the numbers say is the binding constraint:
| Change | Why |
|---|---|
| Anti-affinity: database, sandbox and app on different nodes | one reboot currently takes out two of the three |
| Monitoring and alerting | today's outage was detected by me noticing |
| A bounded startup retry before fail-fast | absorbs a seconds-long database blip without a restart loop |
Thousands to tens of thousands — trigger: cache hit rate falling, or origin CPU sustained above 50%
| Change | Why |
|---|---|
| Move rate-limiter state out of process | the one thing blocking multiple replicas |
| Add a second replica | removes the single-process outage window |
| Make the submission reconciler periodic, not boot-time | with N replicas, a crash is no longer a restart |
| Database replica with a manual failover | the top-ranked constraint, once downtime costs more than embarrassment |
The ordering matters. Replicas are not first: two replicas would silently double the effective rate limit and leave abandoned submissions unswept until some instance happened to boot. The prerequisites are the work; the replica count is a one-line change afterwards.
Hundreds of thousands — trigger: judge queueing delay appears, or write latency rises
| Change | Why |
|---|---|
| Raise the judge semaphore, then add sandbox nodes | the measured ceiling, and the first genuine one |
| Extract execution into its own service | CPU-bound, hostile input, resource profile unlike the rest |
| Read replica for the database | submission reads start to matter |
| Move the origin off a domestic uplink | bandwidth, not compute, is what a home connection runs out of |
Extracting execution is cheap by construction: its port is already a trait, its adapter already speaks HTTP to a separate process, and it already runs on its own node. It is a wiring change, not a rewrite — which is the whole return on having drawn that boundary early.
Millions and beyond — trigger: a single database no longer absorbs the write rate
| Change | Why |
|---|---|
Partition submissions by time |
the only unbounded table; recency is the only access pattern |
| Object storage for source blobs | rows get large; blobs do not belong in a relational store |
| Multi-region origins | physics — 200 ms from another continent is not fixable by faster code |
| Queue-backed judging | fan-out beyond what request-scoped tasks handle |
| Shard by user | only if partitioning demonstrably stops being enough |
Sharding is last and hedged on purpose. At a million monthly readers, submissions arrive at well under one per second; time-partitioning solves the growth problem long before key-sharding is needed, and sharding buys a distributed-systems problem that is very hard to give back.
What stays the same at every stage
The read path. It is edge-cached, derived from a content commit, and reconstructable from a git repository — so it scales by not being answered. Ten times the readers is ten times the cache hits and roughly the same origin load.
That is the highest-leverage property in the system, and it came from one decision made at the start: content is derived data, not database rows. Every capacity number in this chapter is comfortable because of it.
Maintainability is automated or it does not exist
A one-person codebase decays quietly, because there is no reviewer to notice. Six gates run in CI, each catching a specific kind of drift:
| Gate | Catches |
|---|---|
| Domain + viz-engine purity greps | a framework import creeping into pure code |
| File-size caps (500 server / 800 web + viz) | a file quietly becoming two responsibilities |
| Per-page eager-JS budget (250 KiB gz) | an island going eager; dependency weight one library at a time |
| Generated API types are current | the wire contract drifting from the server that defines it |
| Contract lock against the reference spec | an endpoint changing shape without anyone deciding to |
| Formatting + linting, warnings as errors | style drift and known-bad patterns |
They share the property worth copying: each fails the build on a measurable threshold, not on judgement. "Keep files focused" is advice nobody can enforce. "This file is 512 lines and the limit is 500" is a build failure with an obvious next action.
Two of them changed shape when the web tier did, which is the maintenance cost of gates that nobody warns you about. The purity grep was pointed at a Rust client that no longer exists and had to be re-aimed at the visualisation engine — a gate whose subject disappears reports green forever. And the bundle budget was replaced rather than adjusted: with per-page assets there is no bundle to cap, so the threshold moved from 700 KiB of critical path to 250 KiB per page kind. A gate is a measurement, and a measurement outlives its instrument only if someone checks.
The file-size cap has earned its keep — a client file reached 889 lines and the cap forced the split
into logic, state and view it should have had from the start. The gate did not just detect a
problem; it named the fix.
A gate that does not work
Honesty requires naming one that fails. A test diffs the generated API description against a committed snapshot, so contract drift should be a red test. In practice that snapshot documents one endpoint out of roughly twenty, so it has never caught anything.
That is worse than no gate, because it appears in the list above and produces confidence without coverage. A gate that cannot fail is decoration. It should be regenerated in full or deleted — leaving it as ornament is the one option that should be off the table.
The documentation that stays true
Two mechanisms keep the docs from rotting, and both work by removing the option to drift.
The architecture model lives in the content repository and is compiled by the same push that publishes this prose. The chapter and its diagrams cannot disagree, because they are the same commit.
The build book is immutable. Each chapter documents one step and is not retrofitted; later changes are documented forward. That sounds like a recipe for stale docs and is the opposite: a chapter that claims to describe a moment in time is accurate about that moment, whereas a continuously-edited document claims to be current and quietly stops being so.
The failure mode this avoids is the one every project has: a README that was true two years ago,
with no way to tell which sentences still hold.
Most of this plan is deferred. How do you tell a deliberate deferral from an excuse?
By whether three things are true, all checkable by someone else.
The trigger is named and observable. "Raise the judge semaphore when queueing delay appears" is a deferral you can act on, because someone can watch for queueing delay. "We'll split it when we need to" is an excuse, because nothing will ever unambiguously say now.
The cost of deferring is known. The single database is deferred and the price is stated: today that price was ninety-two minutes of downtime, measured. A deferral with an unquantified cost is a guess wearing a plan's clothes.
The design does not foreclose the change. Execution can be extracted because it depends on a port whose adapter already crosses a process boundary. Replicas are blocked by exactly one known thing — per-process rate-limiter state — and that thing is written down in the chapter that owns it. Cheap-to-change-later is what makes deferring rational rather than lucky.
Where this plan was weakest by its own test was capacity itself. Until these measurements existed, "there is plenty of headroom" was an assertion with no number behind it — precisely the kind of claim this book is supposed to refuse. Running the load test turned it into 1,690 req/s, 15,440 req/s, and a ranked list of what breaks first. It also overturned the assumption I would have written down: the content ceiling is the network between two machines in my house, not the application, and I would have blamed the application.
That is the argument for measuring things you are confident about. The plan did not change much — but the reasons did, and reasons are what a future reader has to act on.