English
Performance
In short
- Sync (the everyday write): one changed note pushes in tens of milliseconds even on a 10 000-note vault; the first full import of 10 000 notes takes ~14 s on one core.
- Serving (the read hot path): a single small server (Hetzner cpx32, 4 vCPU) serves ~4 000 real documentation pages per second at 100 % success with a p99 of ~30 ms.
- Memory: ~80 MB per 100 notes, ~1 GB per 10 000.
- Anonymous page cache: a €6.49/month shared box (Hetzner cx23, 2 vCPU) sustains ~8 500 req/s of gzipped pages for 10 continuous minutes: 0 errors, 0 % CPU steal, ≈ €0.0003 per million requests.
Getting an honest serving number turned out to be surprisingly hard — three measurement traps, each costing roughly 2×:
- Dev mode recomputes every asset's hash on each request (~2× slower) → benchmark with
DEV=false. - Load generator on the same box steals CPU from the server (~2× slower) → run it from a separate machine.
- Cold cache / a single URL flatters the result → hit many real pages with a warmed cache.
Our naïve first attempt was ~4× too pessimistic. The numbers below are after controlling for all three. Read on for the detail and the charts.
How fast is a vault sync, and how big can your vault get before you feel it? We measured the pushNotes mutation — the call the Obsidian plugin and the CLI make on every sync — on vaults of 10 to 10 000 notes.
The charts below are live datachart blocks reading the raw benchmark CSV from this vault — the same feature you can use for your own data.
Method
- Each run starts a fresh server with an empty SQLite database, pinned to 1 or 4 CPU cores (
taskset+GOMAXPROCS). - Synthetic notes of ~500 bytes: frontmatter, headings, a wikilink, a list, a code block.
- Initial push — all N notes in one
pushNotescall (one write transaction). - 1 note — a typical small sync: one changed note pushed into a vault of N notes (median of 3).
- 10% of vault — a batch sync: N/10 changed notes in one push (median of 3).
- Memory is the server's resident set size (RSS) after the run; peak from
/proc. - Benchmark script:
scripts/bench-pushnotes.shin the repository.
Important context: while a push is being processed, the server holds the database write lock — other writers (background jobs, other pushes) wait in line. So "push time" here is also "how long the database is locked".
Initial push: whole vault in one call
Scaling is close to linear: ~1.2 s for 1 000 notes on one core, ~14 s for 10 000. Four cores cut that roughly in half: note rendering parallelizes but the database writes do not.
Incremental sync: the everyday case
This is what you feel day to day: you edit a note, the plugin pushes it. Even at 10 000 notes a single-note push takes ~0.4 s on one core and ~0.2 s on four. The renderer caches unchanged notes, so the cost grows with vault size (re-indexing), not with how much you changed — pushing 1 000 changed notes (10% of a 10 000-note vault) takes ~2.3 s on one core, far from 1 000 × the single-note cost.
Memory
The server keeps rendered notes in memory: ~80 MB for a hundred notes, ~180 MB for a thousand, ~1 GB for ten thousand. Plan your hosting accordingly — a 1 GB VPS comfortably serves vaults up to a few thousand notes.
Vector search memory
Enabling vector search (semantic similarity, powered by an embedding model like bge-m3) builds an in-memory index alongside the note cache. The index stores one embedding vector per note — bge-m3 produces 1024-dimensional float32 vectors, so each note costs 4 KB.
| Vault | RSS no vector (1c / 4c) | RSS with vector (1c / 4c) | Overhead |
|---|---|---|---|
| 10 | 67 / 68 MB | 70 / 69 MB | ~2 MB |
| 100 | 82 / 79 MB | 84 / 84 MB | ~5 MB |
| 1 000 | 176 / 136 MB | 205 / 179 MB | +29–43 MB |
| 10 000 | 1050 / 837 MB | 964 / 875 MB | +38 MB |
At small vault sizes the vector index is invisible (40 KB at 10 notes). It becomes significant at 1 000+ notes: ~30–45 MB extra. At 10 000 notes it adds ~40 MB on top of the base note cache — consistent with the theoretical 1 024 dims × 4 bytes × N notes.
Raw numbers
| Vault | Initial push 1c / 4c | 1 note 1c / 4c | 10% batch 1c / 4c | Peak RSS 1c / 4c |
|---|---|---|---|---|
| 10 | 40 / 8 ms | 4 / 2 ms | 4 / 2 ms | 68 / 69 MB |
| 100 | 113 / 81 ms | 6 / 8 ms | 13 / 10 ms | 83 / 80 MB |
| 1 000 | 1.2 / 0.5 s | 39 / 22 ms | 96 / 62 ms | 182 / 149 MB |
| 10 000 | 13.8 / 6.8 s | 382 / 224 ms | 2.3 / 0.7 s | 1186 / 902 MB |
Measured 2026-06-11 on a 12-core ARM64 dev machine restricted with taskset, SQLite in WAL mode, single server process. Your absolute numbers will differ; the shape of the curves will not.
Why page rendering is this fast
The numbers above are for write operations. Page reads are a different story: p50 response time is 3–6 ms at any vault size, even under 10 concurrent users.
This is not an accident; it is an architectural choice. trip2g sits somewhere between a static site generator and a traditional CMS:
- Static generator (Gatsby, Hugo, Eleventy): builds HTML files to disk on deploy. Reads are instant because they're just file serving. But every content change requires a full rebuild — minutes for large sites, no live editing.
- Traditional CMS (WordPress, Ghost): renders every page on request — queries the database, fetches relations, runs templates. Flexible and live, but every reader hits the database.
- trip2g: renders HTML on write (during
pushNotes) and stores the result in memory. Reads serve that pre-rendered HTML directly from a hash map — no database, no template engine on the hot path. The cost of rendering is paid once per content change, shared across all readers.
Keeping thousands of rendered pages in RAM turns out to be surprisingly cheap. A synthetic 500-byte note renders to ~800 bytes of HTML; 10 000 notes is roughly 8 MB of content. The rest of the ~1 GB RSS at that scale is Go runtime, SQLite WAL buffers, and search indexes — not the page cache. A $5/month VPS with 1 GB of RAM comfortably serves vaults of several thousand notes without touching the disk on reads.
There is still room to go further — the current architecture renders notes individually and assembles the final page (layout, injections) on each request. Pre-rendering complete pages would push latency even lower. But even without that, trip2g is already orders of magnitude faster than traditional database-backed CMS platforms on equivalent hardware.
Measured on real content
We loaded this very documentation — 35 real pages (the /en/user set: custom HTML inside the default template) — onto a Hetzner cpx32 (4 vCPU AMD EPYC-Genoa, 8 GB RAM) and hit them with vegeta from a separate machine (so the load generator doesn't steal the server's CPU), round-robin across all pages (~52 KB each), in production mode with a warm cache:
| Load | Success | p50 | p95 | p99 |
|---|---|---|---|---|
| 1 000 req/s | 100 % | 2.7 ms | 3.9 ms | 10 ms |
| 2 000 req/s | 100 % | 1.4 ms | 3.3 ms | 12 ms |
| 3 000 req/s | 100 % | 1.8 ms | 6.2 ms | 16 ms |
| 4 000 req/s | 100 % | 2.6 ms | 13 ms | 32 ms |
| 5 000 req/s | 100 % | 9.1 ms | 74 ms | 169 ms |
| 6 000 req/s | 100 % | 241 ms | 645 ms | 898 ms |
Every request succeeds (100 % 200) right through 6 000 req/s; the limit is latency, not error count. A single 4-core node comfortably serves ~4 000 real pages per second with a p99 of ~30 ms; the knee is around 4 000–5 000 req/s, past which latency climbs into the hundreds of ms as the node becomes CPU-bound. That's the per-node ceiling on this hardware, and where a second node (or a read replica — see zerodowntime / litestream) earns its keep. Measured 2026-06-22.
How to benchmark this honestly. Run with
DEV=false(dev mode recomputes asset hashes per request, ~2× slower), run the load generator on a separate machine (sharing the box steals ~2× the CPU), and hit many real pages with a warm cache. Skipping any of these understated our first run by ~4×.
Two things dominate the per-request cost at the ceiling: gzip compression of each response, and a handful of per-request database lookups in the render path (access check, embedded Telegram links, HTML injections, view tracking). Both are cacheable: pages are static between writes, so there is clear headroom for a future optimization pass to push the ceiling several times higher. (That optimization is planned, not yet done.)
Static files, and a bandwidth reality check
We also served the same 35 pages as plain files from nginx on the same node, to size the engine's overhead against raw static serving. The surprise: at ~52 KB per page, both nginx and trip2g top out in the same low-thousands req/s range — the wall there is network bandwidth (52 KB × 5 000 ≈ 2 Gbit/s) plus per-request gzip, not trip2g's render path. So on this hardware and page size, trip2g already runs close to raw static file serving; the engine's per-request cost (layout assembly + the bookkeeping DB lookups) would only become the dominant limit on smaller pages or a faster link. Placing trip2g cleanly on the spectrum from static files to a database-backed CMS like WordPress needs that cleaner setup (smaller pages / simpler hardware) — noted as future work.
How small a box can run it
We deployed trip2g on a range of cheap VMs and load-tested each from a separate machine (so the generator never steals the server's CPU), in production mode (DEV=false):
| Box | vCPU / RAM | ~Price | Served at 100 % | Restart under load |
|---|---|---|---|---|
| Hetzner cpx32 | 4 / 8 GB | ~€10/mo | ~4 000 rps (real 52 KB doc pages) | — |
| Hetzner cpx22 | 2 / 4 GB | ~€6/mo | ≥4 000 rps (its / landing, ~43 KB — not saturated) |
0 dropped |
| DigitalOcean basic | 1 / 512 MB | $4/mo | ~500 rps* | 0 dropped |
*Its single core holds ~500 rps of the / page (~43 KB) at 100 % with p99 < 50 ms; the knee is ~600–700 rps and past ~900 latency falls apart. Measured with the generator in the same US-East area (~23 ms away) — a first run with the generator in Germany showed a misleading "1 000 rps", but that 86 ms transatlantic link just throttled the generator so the droplet never got pushed. Prices are provider list rates.
Measurement note: these box-comparison numbers predate the 2026-06-29 page cache, so they show throughput without it. Within the table, the Hetzner rows were measured after the earlier write-path optimizations and the DigitalOcean row before them. For page-cache throughput on comparable hardware, see the section below.
Two findings matter more than the raw rates.
A deploy drops nothing. Restarting the service under live traffic — systemctl restart while requests were firing — dropped zero connections on both the 2-core Hetzner node and the $4 droplet (20 000 / 20 000 and 12 000 / 12 000 requests answered 200, no refusals). The listening socket stays open across the restart via systemd socket activation, so in-flight requests wait in the kernel queue for a few hundred milliseconds instead of being refused. One server, no load balancer, no downtime — see zerodowntime.
No object storage needed. With the local file-storage backend (--storage-backend=local), trip2g keeps assets and backups on the server's own disk — no MinIO, no S3. It booted on the $4 droplet using ~57 MB of RAM. The cheapest VM you can rent is enough to run a real site.
What this means for you
- Vaults up to ~1 000 notes: everything is instant — syncs are tens of milliseconds.
- ~10 000 notes on a small server: everyday syncs are still sub-second, but the first push of the whole vault takes ~14 s on one core. During that window the database is locked for other writers; on very large vaults prefer an initial import during quiet hours.
- More cores help rendering, not locking: a 4-core server halves big-push times. The lock is held for the whole push either way, so many concurrent writers gain less than the raw numbers suggest.
Anonymous page cache: 8 500 req/s on a €6.49/month box
The note-cache architecture above pre-renders HTML and holds it in memory, but per-request gzip and a handful of database lookups remain on the hot path. Both costs are now also eliminated for anonymous readers by a second layer: the anonymous page cache, which stores the final pre-gzipped HTML keyed by (path, host, note version, config, language). A cache hit copies bytes out of memory, with no re-render and no gzip.
The effect: a default-template docs page previously cost ~28 ms/request (dominated by a synchronous per-request vector-search "similar notes" pass using cosine similarity over all note embeddings, ~94% of CPU) while a Jet landing page cost ~0.9 ms/request. After the cache warmed, both pages served at the same ~9 000 req/s. The cache equalizes cheap and expensive pages.
Benchmark: 2026-06-29, Hetzner shared-vCPU VMs
| Box | vCPU / RAM | Price |
|---|---|---|
| cx23 (server under test) | 2 / 3.8 GB | €6.49/month |
| cx33 (load generator) | 4 / 7.7 GB | €8.99/month |
Attacked from the cx33 over the network (separate machines, cross-network).
12-second burst (max rate): ~9 000 req/s, server CPU ~78%, steal 0%.
10-minute sustained run (max rate):
| Metric | Value |
|---|---|
| Total requests | 5 134 192 |
| Throughput | 8 557 req/s |
| vs. burst | −5% |
| p50 | 25.8 ms |
| p99 | 86 ms |
| Max | 323 ms |
| Errors | 0 |
| CPU steal | 0% throughout |
Five million requests, ten minutes, zero errors.
The chart below shows how latency changes as the offered request rate climbs toward and past saturation.
p50 stays sub-millisecond up to ~6 000 req/s. The knee is at ~7 000–8 000 req/s, where p50 jumps from 1.2 ms to 146 ms. The box saturates near 9 000 req/s. Success rate was 100% throughout: the server degrades on latency under overload rather than refusing requests.
Cost framing: 8 557 req/s ≈ 739 million requests per day on a €6.49/month box — roughly €0.0003 per million requests, or about €0.29 per billion. For any realistic traffic volume, the serving cost per request is negligible.
Pre-cache comparison (12-core dev box, single page): the default-template docs page served 421 → 8 504 req/s after the cache warmed — a 20× improvement. CPU on cache hits dropped from ~95% to ~0%.
Three measurement traps
Two alternative setups produce misleadingly low numbers:
Loopback (same-box) attack. Running vegeta on the same cx23 measured only ~5 400 req/s — the load generator competed with the server for the same two cores. The server CPU was not at 100%; what we measured was the attacker's ceiling, not the server's. The cross-network number is the real ceiling.
Weak attacker, strong server. Running trip2g on the stronger cx33 and attacking from the cx23 showed only ~44–45% CPU on the cx33. The cx23 could not generate enough load to find the cx33's real limit. That number is attacker-limited, not a server measurement.
CPU steal as the throttle probe. Shared vCPUs can be fair-use-throttled by the provider. The signal is the steal metric in CPU stats. During the entire 10-minute run, steal stayed at 0%: no throttling. For workloads that sustain maximum load 24/7, a dedicated-vCPU instance avoids this risk. For typical bursty traffic, shared is enough.
What the cache covers
The anonymous page cache covers unauthenticated readers only. Logged-in users bypass it. The reason: an authenticated viewer could be an admin (who sees edit controls), a subscriber (who is paywall-exempt), or have other per-user state that differs from the anonymous view; serving them a cached anonymous page risks showing the wrong thing. There is one nuance: on the default template, the admin/anonymous difference (login button, edit badge) is rendered by a client-side script, so the server sends the same HTML to both. The cache could safely extend to logged-in users on default-template pages, but not on custom layouts or paywalled notes. The current design takes the simpler path: any authenticated request bypasses the cache. The underlying synchronous vector-search "similar notes" computation (~28 ms) remains on the read path for cache misses and authenticated requests. The planned next step is precomputing "similar notes" once per note reload, moving the O(N) cosine scan off the read path entirely. The benchmark found this; The page cache and the surprise bottleneck tells the full story.