A database that lives in a bucket
Every app needs a database. None needs a big one
A small app needs four things from a database, and the usual way to get them is priced for a large one.
HanuDB is a small database for people who build a lot of apps. Every app needs somewhere to keep its data, a way to fetch it by id, a way to list it with filters and sorting, and sooner or later a search box.
The usual answer is a managed document database or Postgres, plus a managed search cluster. Each has a minimum price that does not fall when the app is idle, and a storage price that never falls at all. The bill grows with every new app and with every gigabyte each app collects.
Public clouds now give away both halves of another answer: a tiny virtual machine that is always free, and object storage priced in cents per gigabyte a month. What was missing was a database that treats the bucket, not the machine, as the place where the data lives.
A till that sends its roll away
Think of a shop with one till. The till can burn down, as long as the paper roll of every sale is already somewhere safe.
The analogy for the whole article is a shop with one till. The till keeps a paper roll of every sale. In an ordinary shop the roll is photographed every evening and the photos go to a safe across town. If the shop burns down at noon, the morning is lost.
HanuDB is a till that sends its roll away every second, not every evening. The machine is the till. The roll is a log of every write. The safe is a cloud bucket, or two of them in two regions.
So the machine holds no truth. Its disk is only a cache. Delete the machine, start a new one with nothing on it, and it reads the bucket and becomes the database again. Reads never leave the machine, which is why they stay fast.
What a write goes through
One log, synced in fast batches, feeds two engines: a key-value store for lookups and a text index for search.
Every write goes first to one write-ahead log on disk. The record gets the next transaction number and is written into the log. Many writers that arrive together share a single sync to disk; this is called group commit, and it is why thousands of writes a second can each be safe on disk.
From that log two things are fed. A log-structured merge tree, lsm_tree, keeps each document under its collection and id. An inverted index, tantivy, keeps every word and every short field so the document can be found by search, filter and sort. That is how one small binary answers both kinds of question.
The caller chooses how sure to be, per request. none answers after the write is applied in memory. local, the default, answers after the group commit and survives a process crash. bucket answers once the write is in the bucket, or in both buckets, and survives losing the machine.
Segments, a hash chain, and snapshots
The log is cut into chained segments and shipped; snapshots keep a rebuild proportional to the recent tail, not to all the data.
Every second, or every 4 MB, the pending log records are compressed and written to the bucket as one segment. Each segment carries the hash of the one before it and ends with a hash of itself. A reader checks the chain from start to end and rejects any gap, reorder or corruption.
Segments are created with the bucket's create-if-absent rule. A second writer on the same data collides on its first upload and stops. That rule, not a clock, is what prevents two machines from both believing they are the database.
Replaying every segment since the beginning would make a rebuild slower as the data grows. So every 200 segments, when the log has grown enough, the engine takes a physical snapshot: the store and index files themselves, packed and uploaded. A rebuild downloads the newest snapshot and replays only the segments after it. The bucket keeps about three snapshots and a few hundred segments.
The benchmark and its gates
One fixed workload on a one-core, 768 MB container, with gates that make a fast but unsafe change worth zero.
Every measurement in this article comes from one fixed benchmark. Thirty-two clients insert documents of about 450 bytes for 20 seconds. Then thirty-two clients read for 20 seconds: half fetch by id, a quarter run a filtered and sorted find, a quarter run a text search with a filter. Then the server wipes its disk and rebuilds from the bucket, and every write it acknowledged as bucket-durable must come back byte for byte. The score is the square root of writes per second times reads per second.
The server runs in a container pinned to one core with 768 MB of memory, shaped like the free machine. The bucket is a local directory with the delays of a real one added.
A change scores zero if any gate fails: the rebuild must verify, peak memory must stay under 600 MB, bucket write operations must stay under five per thousand writes, and there must be no request errors. Identical builds scored 27,500 and 32,300 in consecutive runs, so every candidate was run twice and kept only if its mean beat the baseline by more than 3 percent, or matched it with less code or less memory.
From 9,047 to 40,116
The score rose more than fourfold: 34,821 writes and 46,218 reads a second on one core, in 355 MB.
The first build scored 9,047. The best build scored 40,116. At that point the engine did 34,821 writes a second with a 99th-percentile write of 4.7 ms, and 46,218 reads a second with a 99th-percentile read of 1.1 ms, with peak memory at 355 MB.
The first figure needs a caveat. The earliest runs used a CPU quota that stalls in 100 ms periods, and a client outside the container. Once the core was pinned and the client moved inside, the same code scored 12,273. Scores before and after that fix are not strictly comparable, so both starting points are given.
The climb had three stretches. The first six experiments fixed the harness and the memory model. Experiments 8 to 53 removed work from the read path, one cost at a time. Experiments 57 to 108 added product features and safety, where the requirement was to hold the score, not to raise it. Disk bytes written per thousand documents fell from 4.54 MB to 2.06 MB on the way.
Fourteen million documents, then pull the plug
A ten-minute soak wrote 14 million documents with no errors, and a wiped machine had all of them back in 40 seconds.
A twenty-second benchmark does not show what ten minutes of writing does to a small machine. So the engine was also run in soaks: ten minutes of continuous writes in the same 768 MB container, with snapshots packing in the background.
The final soak wrote 14,004,270 documents at 23,337 writes a second with zero errors. Memory in use stayed between 240 and 340 MB for the whole write phase, while seven snapshots of up to 4.5 GB were packed.
Then the crash drill. The disk was wiped, the 4.5 GB snapshot was extracted in 10.6 seconds, and the tail of 474,000 log records was replayed: 39.7 seconds in all, and every one of the 14 million documents came back with no mismatch. A restore of a 13.5-million-document log to a moment six minutes earlier took 45 seconds. Thirteen earlier soaks failed before this one passed, and each failure changed the design.
On the real free machine, and what it costs
An app sees sub-millisecond reads on the free machine; sustained heavy writing is its honest limit; the bill was about one cent.
The same binary was deployed on a real free-tier machine with real buckets in two regions. With four clients, which is what a small app looks like, a write took 1.7 ms at the median and 4.8 ms at the 99th percentile. A fetch by id took 0.30 ms, a filtered find 0.43 ms and a text search 0.41 ms.
In ten-second bursts with a single bucket the machine did 4,188 writes and 9,093 reads a second. Sustained for three minutes it fell to about 1,025 writes and about 89 reads a second. The free machine bursts to two vCPUs but sustains a quarter of one, and its disk gives a few dozen operations a second. Bursty traffic is fine. Minutes of heavy ingestion are not.
After the first day, in which every one of these measurements was run against it, each bucket held 211 MB and about 150 write operations had been billed: roughly one cent, with the machine and its disk free. At ten thousand writes a day arriving in bursts, the segment cadence keeps the bucket bill below ten cents a month.
The wins you would expect
Reads got fast by removing one cost at a time; writes got cheap by writing fewer bytes.
Each experiment was one change, measured against the fixed benchmark, and kept only if it held. Of 108 numbered experiments, 74 were kept, 27 were discarded and 3 crashed.
On the read side, checking a filter on a column instead of intersecting lists was worth 34 percent, and caching the answer as one bit per document was worth a further 61 percent. Sending the search engine a bare term query, so that it can skip documents that cannot reach the top results, gave 30 percent more reads.
On the write side, a log file that is filled with zeros once and reused, so that a sync flushes data only, gave 10 percent more writes and 15 percent fewer bytes on disk. The largest single change was removing a second log: the store kept its own journal behind HanuDB's, so each document reached the disk three times. Using the store directly cut disk writes by 20 percent and memory by 80 MB, and gave the best score. Switching the memory allocator made writes 22 percent faster.
The findings that went the other way
On one shared core, a spare thread is a loss, a bigger cache is no gain, and resident memory is the wrong thing to watch.
About a quarter of the experiments were discarded, and several of them overturned a reasonable belief. Moving the sealing of segments onto a spare thread, to free the write lock, made writes 13 percent slower. On one core a second thread only competes with request handling. Blocking writers for microseconds is free; extra CPU is not.
A short wait to batch more writes into each sync cut writes by 44 percent, because the timer added about a millisecond to every write. A larger block cache changed nothing at any size from 32 to 256 MB except the memory bill.
The soaks taught the hardest lesson. Resident memory is the wrong quantity for an out-of-memory guard: it counts clean index pages that the kernel drops under pressure. What kills a process in a container is its own memory plus pages waiting to be written to disk. On the benchmark that was 394 MB of real pressure for 464 MB resident. And a minute-long job must never sit inside the work that seals and uploads: moving snapshot packing to its own task turned 213,786 client timeouts into zero.
What it is not for, and where everything is
A single machine is its ceiling. Inside that ceiling everything is open: the code, the experiments and the paper.
HanuDB is a single-writer database, and its ceiling is the machine it runs on. It does not scale writes beyond one core, has no interactive transactions across requests, no sharding and no query planner. A hot set larger than memory falls to the disk's speed. Automatic failover needs a second machine, which is not free. Those are the reasons to move to a hosted database once an app outgrows a few thousand concurrent users.
Inside that ceiling it is complete: a 58-check correctness suite, point-in-time restore, a follower for reads, clients for TypeScript, Python and Swift, and a one-line installer. It was first called TwinDB, after its twin-bucket design, and a few commands still answer to that name.
It is open source. The code is at github.com/madhudream/hanudb and the explainer at db.carebun.com. Every experiment, kept or discarded, is in the experiments page, and the full method and every table are in the paper.
Step 2: the experiments