MonadDb
Blockchain nodes need a database. Every time the EVM reads an account balance, loads contract code, or checks a storage slot, it is reading from a data structure called the state trie -- a Merkle-Patricia Trie that maps keys (account addresses, storage slots) to values (balances, nonces, bytecode). Every time a transaction executes, the trie gets updated, and a new state root hash is computed that cryptographically commits to the entire state.
The performance of this database directly determines how fast a node can execute transactions. If every state access requires multiple disk reads through layers of indirection, execution speed is bottlenecked by storage latency regardless of how fast the CPU is.
MonadDb is Monad's custom-built state database. Rather than using an off-the-shelf key-value store, MonadDb implements the Merkle-Patricia Trie as a first-class on-disk data structure, with asynchronous I/O and SSD-aware storage layout. This article explains why that matters and how it works.
The problem with generic key-value stores
Most Ethereum clients store the state trie in a general-purpose key-value store like LevelDB, RocksDB, or LMDB. These are mature, well-tested databases built around their own internal data structures: B-trees (LMDB) or log-structured merge trees (LevelDB, RocksDB).
The trie is embedded inside the KV store by using trie node hashes as keys and serialized node data as values. To look up an account, the client traverses the trie from the root, and at each level it issues a KV lookup (e.g. a B-tree traversal) to fetch the next node by its hash.
This creates a data structure layering problem: the client is traversing one tree (the Merkle-Patricia Trie) that's stored inside another tree (the B-tree or LSM tree). A single account lookup might require 6-8 trie levels, and each level requires an independent KV lookup, each of which might itself require multiple disk reads within the KV store's internal structure. The indirection compounds.
Beyond the read path, writes are also affected. LSM trees buffer writes in memory and periodically compact them to disk in a process that rewrites large portions of data. This write amplification -- where a single logical write causes many physical writes -- is a well-known cost of LSM-tree-based stores, and it competes for SSD bandwidth with the reads that execution needs.
MonadDb: the trie is the database
MonadDb eliminates the indirection layer by implementing the Merkle-Patricia Trie directly as an on-disk data structure. Trie nodes are not looked up by hash through a generic index -- they are located by direct physical pointers.
Each node in the trie stores, for each of its children, a chunk_offset value that encodes the child's exact on-disk location. When traversing from parent to child, there is no index lookup, no hash table probe, no B-tree descent. The parent knows precisely where the child lives on disk, and the I/O subsystem can issue a read for exactly those bytes.
This section explains the key design elements that make this work.
Trie node structure
Check out the code: category/mpt/node.hpp
MonadDb uses a generalized trie that extends the standard Ethereum Merkle-Patricia Trie. Nodes can have up to 16 children (one per hex nibble), an optional key path, and an optional value. The generalizations allow a single node to serve dual roles -- for example, a node can simultaneously be an extension (having a path) and a branch (having multiple children), or a branch with a leaf value.
The on-disk representation of a node packs the following data sequentially:
Header fields: A 16-bit child mask (indicating which of the 16 possible children exist), a bitpacked byte (has-value flag, path nibble indices, intermediate hash cache size), value length, and a version number (the block number at which this node was last updated).
Child offset array: For each child present (as indicated by the mask), an 8-byte chunk_offset pointing to the child's on-disk location.
Compaction metadata: Per-child minimum offsets and minimum versions for the subtrie rooted at each child. These fields enable the compactor to determine which regions of storage contain only expired data.
Child data offset array: A 2-byte offset for each child, pointing into the child data section. This allows locating any child's data without scanning.
Path: The nibble path for this node (variable length), used for prefix compression in the trie.
Value: The user-facing leaf data (variable length) -- e.g. the RLP encoding of an account's nonce, balance, and code hash.
Intermediate hash cache: For nodes that are both a leaf of one sub-trie and the root of another (e.g. an account node that roots a storage trie), a cached intermediate hash.
Child data: The hash data of all children, concatenated. This is the key to efficient hash computation -- see below.
In-memory child pointers: When a node is loaded into memory, space is appended for shared_ptr references to child nodes that are already in memory. These pointers are not persisted to disk.
Inline child data: avoiding reads for hashing
A central design choice is storing each child's hash data inline in the parent node. In a standard implementation, computing a node's merkle hash requires reading all of its children to retrieve their hashes. In MonadDb, those hashes are already embedded in the parent's on-disk representation.
This means that when the trie is updated and hashes need to be recomputed along the path from a modified leaf to the root, each node along that path already contains the hash data it needs for all of its children -- it only needs to re-read the single child that changed. The number of disk reads required for hash recomputation is proportional to the depth of the modification, not the branching factor.
The chunk_offset: location + size in 64 bits
Check out the code: category/async/config.hpp
Each child pointer in the trie is a 64-bit chunk_offset packed as a bitfield:
bit 0-27: offset within the chunk (28 bits, max 256 MB)
bit 28-47: chunk id (20 bits, max ~1 million chunks = 256 TB addressable)
bit 48-62: spare bits (15 bits, used by the trie)
bit 63: format flag (reserved)
The 15 spare bits are used by MonadDb to encode the on-disk size of the child node, expressed as a page count with a shift:
bit 0-9: page count (10 bits, max 1023)
bit 10-14: shift (5 bits, max 31)
disk pages = count << shift
This means a single 64-bit value tells MonadDb both where a node is and how many bytes to read. When the I/O subsystem receives a read request for a child node, it can issue a precisely-sized read with no preliminary I/O to determine the node's size. The encoding is approximate (it rounds up), so the read may fetch slightly more bytes than necessary, but never fewer.
Asynchronous I/O with io_uring
Check out the code: category/async/io.hpp
Parallel transaction execution means that multiple transactions are executing concurrently, each potentially reading different parts of the state trie. If disk reads block the calling thread, parallelism is limited by the number of threads, and most of those threads spend their time waiting for I/O rather than doing useful work.
MonadDb uses Linux's io_uring interface for fully asynchronous disk I/O. When a trie traversal reaches a node that isn't in memory, it submits a read request to the io_uring submission queue and yields control (via a lightweight Boost Fiber coroutine) so that other work can proceed. When the kernel completes the read, the completion is picked up from the io_uring completion queue and the waiting fiber is resumed.
This allows hundreds or thousands of concurrent state reads to be in flight simultaneously, all multiplexed over a small number of OS threads. The number of concurrent reads is configurable (the default is 1024 for a read-write database and 600 for read-only), providing backpressure to avoid overwhelming the storage device.
Read buffers are managed in two tiers:
Short reads (up to 4 KB): Drawn from a pre-allocated pool of fixed-size buffers, avoiding per-read memory allocation.
Long reads (above 4 KB): Dynamically allocated with the alignment required for direct I/O.
Storage layout: chunks and zones
Check out the code: category/async/storage_pool.hpp
MonadDb divides its storage into 256 MB chunks, inspired by NVMe zoned namespace (ZNS) storage. Even when running on conventional SSDs, MonadDb emulates zoned storage semantics, dividing a raw block device or file into chunks of two types:
Conventional chunks (cnv): Used for metadata that needs random access, such as the root offset ring buffer and database metadata. There are typically a small number of these (default: 3 per device).
Sequential chunks (seq): Used for trie node data. These are treated as append-only: new nodes are written sequentially to the end of the current chunk, and once a chunk is full, writing moves to the next one. Old chunks can be recycled in bulk once their data is no longer needed.
The sequential write pattern has significant SSD performance benefits. SSDs internally organize NAND flash into large erase blocks. Random writes to arbitrary locations create fragmentation that forces the SSD's garbage collector to copy live data out of partially-used erase blocks before they can be reclaimed -- a process that consumes bandwidth and increases latency unpredictably. By writing sequentially and recycling entire chunks at a time, MonadDb avoids this internal fragmentation. When a chunk is recycled, MonadDb issues a TRIM command, allowing the SSD to efficiently reclaim the underlying flash blocks.
On devices that support NVMe zoned namespaces, the sequential chunks map directly onto the device's append-only zones, bypassing the SSD's internal block device emulation layer entirely. This can reduce read latency from the typical ~70 microseconds down to 15-30 microseconds, because reads from sequential zones go directly to the NAND flash without passing through the SSD's flash translation layer.
Filesystem bypass
MonadDb can operate directly on a raw block device, bypassing the filesystem entirely. This eliminates filesystem overhead including block allocation, directory metadata management, and journaling. MonadDb manages its own block allocation through the chunk system, which is simpler and more predictable than a general-purpose filesystem because the access patterns are known in advance.
Persistent tries: versioning without locks
Check out the code: category/mpt/db.hpp
MonadDb uses a persistent (in the functional programming sense) trie structure. When a block updates some accounts, MonadDb does not modify existing nodes in place. Instead, it creates new versions of the modified nodes and writes them to fresh locations on disk. The old nodes remain intact and readable at their original locations.
This means multiple versions of the state trie coexist on disk, each accessible by its root offset. A ring buffer in the conventional chunk stores the root offset for each block number, allowing any historical version (within the retention window) to be looked up by block number.
The persistence model has important concurrency benefits. Readers accessing the state at a particular block number follow pointers through an immutable snapshot of the trie -- they never observe partially-written updates, and they require no locks or coordination with the writer. This is exactly what's needed for a blockchain node where the execution engine is writing new state while the RPC server and consensus layer are concurrently reading state at various recent block heights.
Version tracking
Each trie node records the block number at which it was last updated (in the version field). For leaf nodes, this is the block that last modified the value. For interior nodes, the version is at least as large as the maximum version of any leaf in its subtrie.
This per-node versioning supports efficient history expiration. The compactor can examine a subtrie's minimum version to determine whether any of its data falls within the retention window, and skip entire subtries that are known to contain only current data.
Compaction
As new blocks produce new node versions, old versions accumulate on disk. MonadDb reclaims space through compaction: it walks the live trie, copies reachable nodes forward to fresh sequential chunks, and recycles the old chunks.
Chunks are organized into two linked lists -- a fast list and a slow list -- that support tiered storage. Hot data (recently written or frequently accessed nodes) lives in fast-list chunks, while cold data migrates to the slow list. Fully compacted chunks move to a free list for reuse.
The compaction process runs inline with normal updates, throttled to avoid interfering with execution throughput. The database dynamically adjusts its history retention length based on available disk space.
Crash safety
Check out the code: category/mpt/detail/db_metadata.hpp
MonadDb maintains two copies of its database metadata, stored in conventional chunks. Updates alternate between the two copies, so if the process crashes during a metadata write, at least one copy remains valid. On startup, MonadDb reads both copies and uses the consistent one.
Unified trie with nibble-based partitioning
Check out the code: monad-triedb README
MonadDb stores all blockchain data -- account state, contract code, transaction receipts, block headers, and secondary indexes -- in a single unified trie. Data types are separated by a leading nibble prefix:
| Nibble | Data type | Key structure |
|---|---|---|
| 0 | Account state | 0 + keccak256(address) |
| 0 | Storage slot | 0 + keccak256(address) + keccak256(slot_key) |
| 1 | Contract code | 1 + code_hash |
| 2 | Receipt | 2 + rlp(tx_index) |
| 3 | Transaction | 3 + rlp(tx_index) |
| 4 | Block header | 4 |
| 7 | Tx hash index | 7 + keccak256(rlp(tx)) |
| 8 | Block hash index | 8 + keccak256(rlp(header)) |
| 9 | Call frames | 9 + tx_index + chunk_index |
Receipts, transactions, and block headers are versioned by block number, so the same key structure retrieves different data depending on which version (block number) is queried.
The secondary indexes (tx hash and block hash) enable the standard Ethereum RPC lookups like eth_getTransactionByHash -- given a transaction hash, the index returns the block number and transaction index, which can then be used to fetch the full transaction data.
This unified approach means there's a single storage engine to optimize, a single compaction process, and a single caching layer, rather than separate systems for different data types.
Caching
Check out the code: category/mpt/node_cache.hpp, category/core/lru/lru_cache.hpp
MonadDb maintains an in-memory LRU cache of recently-accessed trie nodes, keyed by their virtual chunk offset. The cache is bounded by memory (default 50 MB for read-only databases) rather than entry count, and it tracks the actual size of each cached node.
With an average node size of ~104 bytes, a 50 MB cache holds roughly 500,000 nodes. Since the upper levels of the trie are accessed far more frequently than the leaves, the cache naturally retains the hot interior nodes and provides a high hit rate for typical workloads.
On the Rust side, the consensus and RPC layers add additional caching. A per-block cache retains account data and block headers for recently finalized blocks, and the RPC server pre-loads transactions and receipts for the latest blocks to serve common queries without hitting the trie.
Integration
Check out the code: monad-triedb/src/lib.rs
MonadDb's core (the trie, the I/O layer, and the storage pool) is implemented in C++ as part of the execution engine. The consensus client and RPC server, written in Rust, access the database through a C FFI boundary.
The FFI interface exposes a small set of operations: synchronous reads, asynchronous reads with callbacks, prefix traversals, and range queries. On the Rust side, a dedicated polling thread drives the io_uring completion loop, translating C++ callbacks into Rust channel messages. This keeps the async I/O machinery contained within the C++ layer while giving Rust code a clean interface for concurrent state access.
The Rust side is read-only with respect to the trie. State writes happen entirely within the C++ execution engine, which writes new node versions as blocks are executed. The Rust consensus layer queries the database to check finalized state, read validator sets, and serve RPC requests, but it never modifies the trie directly. This separation ensures there's a single writer and multiple readers, which aligns naturally with the persistent trie's concurrency model.
Originally published on X on February 8, 2026.