I ordered the cheapest Hetzner CX23 ($4.99/mo, shared-resources tier) and ran a real-workload SQLite benchmark on it. Not a microbenchmark — a 6 GB database on a box with 3.7 GB of RAM, so reads actually have to touch disk.
Shared vCPUs with noisy neighbors:
noatime), scheduler=noneLatest SQLite 3.53.1, statically linked into a C bench.
Production-realistic pragmas — not turned-up-to-eleven:
journal_mode = WAL
synchronous = NORMAL -- safe, not strict-ACID durable
page_size = 8192
cache_size = 256 MiB
mmap_size = 256 MiB
temp_store = MEMORY
busy_timeout = 5000
One caveat worth being explicit about: synchronous=NORMAL is not durable in
the strict ACID sense. A power loss can roll back the last few committed transactions —
whatever was in the WAL but not yet fsync'd to the main DB file. The database file
itself will not corrupt — corruption is what synchronous=OFF risks;
FULL avoids the loss entirely; NORMAL only loses the tail. For most apps
that's a fine trade for the speedup. If you can't afford to lose any committed write — payments,
audit logs — use synchronous=FULL.
WAL also matters for the concurrency numbers below: readers don't block the writer and the writer doesn't block readers, which is why concurrent reads scale almost linearly across cores while writes still serialize.
PK + 2 indexed int columns + 500 B text payload| Phase | Throughput | p50 | p95 | p99 | p999 |
|---|---|---|---|---|---|
| BULK INSERT 10M × 500B | 61,300/s | 8 µs | 58 µs | 80 µs | 143 µs |
| SELECT random PK | 3,609/s | 265 µs | 476 µs | 715 µs | 2.3 ms |
| SELECT indexed range scan | 3,477/s | 290 µs | 599 µs | 895 µs | 2.9 ms |
| UPDATE per-row, 1-txn | 2,986/s | 257 µs | 473 µs | 706 µs | 2.2 ms |
| MIXED 70R/25U/5I (OLTP) | 3,915/s | 257 µs | 455 µs | 710 µs | 2.2 ms |
| CONCURRENT reads (4 threads, 5s) | 14,396/s | 162 µs | 790 µs | 2.2 ms | 4.4 ms |
| CONCURRENT writes (1 thread, 5s) | 3,305/s | 180 µs | 690 µs | 1.3 ms | 6.0 ms |
Working set spills past RAM — every random read pays for I/O.
1M rows × 200 B ≈ 246 MB — comfortably cached. This is the engine ceiling on a $5 VPS:
| Phase | Throughput |
|---|---|
| INSERT 1-txn (prepared bind) | 124,123/s |
| SELECT random PK | 155,828/s |
| SELECT indexed range | 58,239/s |
| UPDATE per-row 1-txn | 89,642/s |
| UPDATE bulk-join (set-update) | 57,519/s |
| MIXED 70R/25U/5I | 53,284/s |
| CONCURRENT reads (4 threads) | 103,894/s |
| CONCURRENT writes (1 thread) | 1,055/s |
Pure CPU+lock cost, no disk in the hot path.
Random reads drop 43× the moment the working set outgrows RAM:
| Phase | In-RAM | DB > RAM | Δ |
|---|---|---|---|
| INSERT bulk | 124k/s | 61k/s | 0.5× |
| SELECT random PK | 156k/s | 3.6k/s | 43× |
| SELECT range | 58k/s | 3.5k/s | 17× |
| UPDATE per-row | 90k/s | 3.0k/s | 30× |
| MIXED OLTP | 53k/s | 3.9k/s | 14× |
| CONCURRENT reads | 104k/s | 14k/s | 7× |
| CONCURRENT writes | 1.0k/s | 3.3k/s | 0.3× |
Reads collapse. Concurrent writes go the other way — see below.
Concurrent writes ran 3.3k/s on disk vs 1.0k/s in RAM. That looked wrong, but here's what's going on:
Making writes faster doesn't help when the bottleneck is taking turns, not doing the work.
SQLite on the cheapest shared-CPU Hetzner handles real production load.
Realistic OLTP mix (70R / 25U / 5I): 3.9k ops/s, p99 = 710 µs, p999 = 2.2 ms.
= 14 million ops/hour on a $5 VPS, with sub-3 ms tail latency.
SQLite is enough for most of us.