Recently, I set up a Base Mainnet RPC on one of my own machines.
The original requirement was actually simple. I have been experimenting with connecting AI to onchain data exploration: letting an agent read pool state on its own, batch-simulate contract calls, compare results across different blocks, and then organize the data into context that can drive the next round of research. Public RPCs can certainly do this too, but once queries become frequent and the methods become more specialized, rate limits, latency, historical range, and provider support quickly become new constraints.
So I wanted an RPC I could control myself. The reason was practical: I wanted control over the data source, the simulation methods, and the tools I might add later.
Once I actually started building it, I realized that the hardest part was definitely not docker compose up.
First problem: an official estimate of 782 GB does not mean a 1 TB disk is enough
Base currently provides official snapshots. The Minimal snapshot contains only state and headers and is listed at about 782 GB, while Full is close to 918 GB. At first glance, if you have a 2 TB NVMe drive, it looks like there should be plenty of room for a pruned node.

Figure: Base’s official snapshot page. Minimal is the smallest download option, but “download size” is not the same as the total disk space you will ultimately consume.
Base’s separate Node Performance documentation is much more conservative about capacity. You need to account for the current chain size, the snapshot restore process, and additional buffer space together. This is not unnecessary caution. In my actual run, the compressed state data referenced by the manifest was about 731 GB, while the extracted output was close to 1.1 TB. After the node finished syncing, my reth-data directory was already about 1.2 TB, and total usage on the data disk had exceeded 1.38 TB.

Figure: Base’s official Node Performance documentation. The easy part to underestimate is the extra space required for snapshot restore and subsequent chain growth.
That was my first correction in thinking: the number on the snapshot page is closer to “how much data you need to move over,” not “how much space your node will use in the end.”
If you buy storage based only on the download size, you can discover the shortage at the worst possible moment: after downloading hundreds of gigabytes and getting deep into extraction, only to be forced to start over.
Second problem: I assumed disabling resumable downloads would avoid another huge cache
I used Base’s official base/base node image, with Base Reth as the execution layer and Base consensus running alongside it. Because this RPC is mainly for current-state and recent-data exploration, I did not build an archive node. Instead, I configured several data types to retain the most recent 50,000 blocks.
There was one very practical requirement: I wanted the RPC to support Reth’s native eth_callMany.
This is not JSON-RPC batching, and it is not a Multicall contract. It lets you execute a set of simulated calls sequentially inside the same bundle, so later calls can observe the simulated state changes produced by earlier calls, while none of it is actually submitted onchain. For an AI agent, this is more interesting than issuing eth_call one by one because the agent can run a sequence of assumptions as a small experiment.
The node’s Compose setup itself is simple: execution exposes 8545/8546 on localhost, consensus runs as a separate service, and the P2P port is exposed externally. The part that consumed time was restoring the snapshot.
At first, I used Reth’s built-in snapshot downloader directly. I even tested --resumable=false, assuming that would prevent it from keeping a full compressed copy on disk. In practice, this version still created a sparse cache of about 709 GB on both the manifest path and the single-archive path.
For a machine where disk space was already tight, this was fatal. You already need room for an extracted database larger than 1 TB. If the downloader keeps another cache of more than 700 GB beside it, a 2 TB NVMe quickly runs out of working space.
So I stopped fighting the downloader flags and changed the download method instead.
I ended up building my own range streaming approach: each failure only costs me 64 MiB.
I wrote a small range streamer that splits the snapshot into fixed-size HTTP ranges. The current default is 64 MiB per segment. Each segment is received in full before it is sent into zstd, then piped directly into tar and extracted into Reth’s data directory.
Conceptually, it looks like this:
HTTP Range (64 MiB)
↓
zstd
↓
tar
↓
/data/db
This did not magically make the download faster. What it changed was how much progress a single failure could destroy.
I actually hit HTTP/2 connection failures during the process. With one giant stream from beginning to end, the connection might fail after several hundred gigabytes. With ranges, a network failure means downloading only the current 64 MiB again. The program automatically retries that range and then continues to the next one.
This is still not perfect “resume across processes.” If the entire download process or the host stops, I cannot guarantee that a database being extracted as a stream can safely continue from that exact point. I still remove the incomplete DB state and rerun the restore. But at least an ordinary network hiccup no longer means redoing 700 GB.
I also added a disk watchdog that checks the data disk every five seconds. If free space falls below 150 GiB, it stops the snapshot process immediately. Download and execution are mutually exclusive as well, so another restore process cannot modify the same database while the node has it open.
These are not the most visible parts of a “how to run a Base node” guide, but they became the engineering work I cared about most: do not design only for the success path. Decide in advance what you will do when the process fails after 700 GB.
Memory tuning is not one number; it is controlling how much data each stage consumes
This machine has 64 GB of RAM, so I did not treat Reth as something where “more memory is always better.” The useful tuning was limiting how much data different sync stages can buffer and process at once, so memory, SSD I/O, and synchronization speed do not work against each other.
For example, in my current Reth configuration, the Bodies downloader can buffer about 2 GiB of blocks. Execution, Account Hashing, and Storage Hashing each have their own block / changes / commit thresholds, and the ETL file size is fixed at about 500 MiB. These settings do not mean “Reth can only use this much RAM,” but they directly affect how much work a stage accumulates at once, how often it writes back to the database, and how much burst pressure the SSD receives.
When tuning the system, I cared less about filling RAM than about keeping Execution and MerkleExecute moving continuously. If a batch is too large, throughput may look high for a while, but random I/O and commit pressure become concentrated on the SSD. If the batch is too small, switching and commit overhead increase. I ended up with a conservative setup on this machine: leave enough page cache for Linux and MDBX, give Reth room to absorb reads, and prevent any one synchronization stage from consuming all resources at once.
A running node does not mean the RPC is trustworthy yet
After the snapshot restore completed, the next problem was how easy it is to declare success too early just because the endpoint “responds.”
An eth_chainId response of 8453 does not mean the node has caught up with Base Mainnet. A moment where eth_syncing returns false also does not, by itself, prove that you have the latest state. While my node was catching up, it was once more than 500 blocks behind and could still answer many RPC queries normally. If you used it for real-time market decisions at that point, you would simply get a very detailed description of the past.
So I eventually wrote a status.py script that checks several things at once:
- whether the execution / consensus containers are running normally
- whether the chain ID is 8453
- latest / safe / finalized block heights
- how old the latest block timestamp is
eth_syncingand peer count- the block gap between the local node and an external Base RPC
- free space on the data disk
I later found that looking at a single block-gap sample can be misleading. Sometimes the node catches up to within a dozen blocks, then falls farther behind again a few minutes later. eth_syncing can even return false while the RPC head still has not stabilized close to the chain tip.
Looking further into the Reth stage logs made the reason clearer. The node was not simply “too slow to process new blocks.” After a restart, it was continuing unfinished synchronization work from earlier: running part of Execution, then entering MerkleExecute to calculate the state root for the corresponding blocks. Only after that pipeline cycle completed would the RPC head continue moving forward. In one observation, MerkleExecute advanced from 53,228,153 to 53,228,198 in about 14 seconds. Execution then processed 53,228,199 through 53,228,280 in about 119 seconds before returning to the Merkle stage. New blocks were still being received by the node throughout this time.
That distinction matters. On the surface, both cases look like “the node is dozens of blocks behind,” but the cause may not be a completely stuck node, and it does not necessarily mean the SSD simply cannot keep up with Base’s real-time block production. The node may be consuming historical pipeline backlog. During MerkleExecute, NVMe utilization reached about 97% at one point, driven mostly by large numbers of 4 KB random reads, so storage I/O is still a bottleneck worth watching. But I would not replace the SSD based on one high-utilization sample. A better judgment comes from checking whether stage checkpoints keep advancing, how many blocks are actually processed per minute, and whether the block gap shrinks or expands over a longer period.
This also changed how I think about RPC health. I no longer ask only whether the endpoint “connects” or whether eth_syncing is false. I treat it as a data source whose freshness and synchronization progress need continuous measurement. For ordinary onchain exploration, it remains useful as long as the state is fresh enough and latency is acceptable. If the target is ultra-low-latency arbitrage, then block age, block gap, and whether the pipeline consistently stays near the chain tip need much stricter standards.
Is an RPC that is one block behind still useful?
Yes. For my use case, it is still very useful.
This RPC is not primarily for making the final millisecond-level transaction decision. It is for tracking DEX onchain state and pairing that data with AI contract analysis through eth_call / eth_callMany. The agent can read pool state, token balances, contract storage, and route information, then decide what to simulate next based on the previous result.
For this kind of work, being one block behind is completely different from being dozens or hundreds of blocks behind. When the gap is only one block, most contract structure, pool composition, route relationships, token metadata, and a large amount of state that does not change every block are still useful for exploration, filtering, and candidate analysis. It is also well suited to large volumes of reads and simulations, so not every research query has to hit an external RPC.
But if the analysis is going to become a real-time transaction, the final step still has to revalidate against the latest block. A DEX reserve, tick, liquidity value, nonce, or other state that directly affects transaction results may already be different one block later. My approach is to use the local RPC as a research and simulation layer: do large-scale exploration and candidate generation locally, then revalidate against the newest state at the final latency-sensitive step.
So I do not track block gap because the whole RPC becomes worthless the moment the gap is not zero. The real question is: is it suitable for the job I want to do right now? For DEX data exploration and AI contract analysis, being 0–1 block behind is usually still very useful. If the gap keeps growing, or block age stretches into tens of seconds or minutes, then I treat it as a synchronization-quality problem rather than a momentary difference.
For me, this Base RPC is a controllable onchain research data source. A DEX tracking program collects changes from the chain, and AI then uses eth_call, eth_callMany, and contract state for deeper analysis. It does not need to do everything. As long as I know how fresh it is and what the synchronization pipeline is doing, it becomes a very practical layer of infrastructure.
