Igor Mandrigin

Semi-Stateless Initial Sync Experiment

Block witnesses pre-build cache tries during initial sync. The archived sample averages 0.037598 MiB per recorded witness entry after block 3,000,000.

· 6 min read · Updated · markdown

Historical piece — some technical details may be outdated.

Originally published on Medium. Mirrored Aug 22, 2026.

Editorial correction — September 5, 2026: the original conclusion said “around 0.4 MB” per block. Recalculating the archived CSV reproduces the published mean as 0.037598 MiB per recorded witness entry, about 39,424 bytes. The old plots label binary megabytes as MB, and the archive does not contain a row for every block. The verification below documents the units, sample and limits; the original publication date is unchanged.

Raw data and scripts for the experiment: https://github.com/mandrigin/ethereum-mainnet-resolver-witness-stats

How semi-stateless sync works §

One of the possible ideas to speed up the initial sync process it to use block witnesses to pre-build the cache trie to avoid slow state access. That will cost additional disk space and network bandwidth but potentially can significantly speed up the sync process.

How will that work? Basically, to run each block, we need some data in a Merkle trie. Before the block execution we already have some nodes there, but it might not be enough to run the block. Normally, this data will be taken from the state db and then added to the trie while the transactions are being executed. That might be slow because of the disk access/db lookups.

So we have 3 types of a flow here:

1)Normal flow (as currently in Ethereum nodes)

  • Before the block B is executed, we have a trie T1.
  • As we execute the block, we add missing bits and pieces to the trie, making it T1’, T1'', etc. Every time we miss some information, we look it up from the database (slow).
  • After the block B is executed we have the trie T2 that has all the account states to run the block B.
  • We keep T2 for the future resolutions.

2)Stateless flow

  • Before the block B is executed, we don’t have a trie and we have a witness W to reconstruct the trie required to run this block.
  • We execute W and get the trie T2.
  • We execute the block B on T2, no DB lookups needed.
  • We throw away T2 after the block is executed.

3)Semi-stateless flow (this experiment)

  • Before the block B is executed, we have a trie T1 and witnesses W1, W2, … enough to convert T1 into T2.
  • We execute W1, W2, etc on T1, and get the trie T2. No DB lookups needed.
  • We execute the block B on T2, no DB lookups needed.
  • We keep T2 for the future resolutions.

Semi-stateless (3) flow on initial sync potentially gives most of the benefits of the stateless flow (2), but should require less data to sync due to reusing trie cache.

parallel block execution will be limited to some extent with semi-stateless approach.

So, to test the Semi-Stateless approach, we need to measure 2 things:

  • how much additional space/bandwidth is required for this approach; is it any better than the fully stateful approach?
  • how much faster does it make the initial sync.

In this article, we will focus on the disk space.

Setting up the experiment §

  • Max size of the trie (Merkle trie): 1.000.000 nodes. When the number of nodes exceeds this value, the LRU nodes will be evicted to free up the memory. This way we can keep the used RAM under control.
  • The partial witnesses are stored in a db (our fork of boltdb). Each entry has the following structure:
key: [12]byte // block number + max number of nodes in the trie
value: []byte // witnessses, serialized as described in https://github.com/ledgerwatch/turbo-geth/blob/master/docs/programmers_guide/witness_format.md
  • We don’t store the contract code in the witnesses (that is a limitation of the current architecture).

How the data was collected (required a synced turbo-geth).

(in the turbo-geth repository)
make state
./build/bin/state stateless \
     — chaindata ~/nvme1/mainnet/mainnet/geth/chaindata \
     — statefile semi_stateless.statefile \
     — snapshotInterval 1000000 \
     — snapshotFrom 10000000 \
     — statsfile new_witness.stats.compressed.2.csv \
     — witnessDbFile semi_stateless_witnesses.db \
     — statelessResolver \
     — triesize 1000000 \

Experiment Results §

Total Storage §

Witnesses DB (bolt db) to sync 6.169.246 blocks from scratch takes 99Gb

Quantile analysis §

python quantile-analysis.py cache_1_000_000/semi_stateless_witnesses.db.stats.1.csv
mean   0.038 MB
median 0.028 MB
p90    0.085 MB
p95    0.102 MB
p99    0.146 MB
max    2.350 MB

Verified measurements — September 5, 2026 §

The table below recalculates witnessesSize from the archived CSV, selecting blockNum >= 3000000. That selects 2,395,470 recorded entries, from block 3,000,079 through 6,169,246, all with maxTrieSize = 1000000. The entries’ sizes sum to 94,438,666,501 bytes. A MiB is 1,048,576 bytes; quantiles use linear interpolation between sorted observations.

StatisticSize (MiB)
Mean0.037598
Median0.028214
90th percentile0.085464
95th percentile0.101974
99th percentile0.146458
Maximum2.352358

These reproduce the repository’s reported results. Its README describes a 3,000,000–6,000,000 range, but the analysis script accepts a lower bound only. Restricting the same file to block 6,000,000 gives 2,227,185 entries and a different mean, 0.035739 MiB. The original rounded readout above is retained with its original labels; use the verified table when citing these results.

This is a historical sample of serialized partial witnesses, without contract code. Missing block numbers are not counted as zero-size witnesses. The figures therefore describe recorded entries, not an average network cost across every block, a complete storage requirement, or a measured sync-speed improvement. The recalculation script checks the archived file hashes and reproduces the table.

Full Data §

python absolute_values_plot.py cache_1_000_000/semi_stateless_witnesses.db.stats.1.csv

Witnesses sizes for blocks from 1 to 6100.000, capped at 1.0 MB. Sliding avg 1024.

Normalized Data (after DDoSes) §

absolute_values_plot.py cache_1_000_000/semi_stateless_witnesses.db.stats.1.csv 3000000

Witnesses sizes after DDoS values, sliding avg 1024.

DDos Zoom In §

python ddos_zoom.py cache_1_000_000/semi_stateless_witnesses.db.stats.1.csv

Zoomed in section to DDoSes influence on witnesses sizes (raw data).

We can see that due to the DDoSes around blocks 2.3M-2.5M and 2.65M-2.75M the sizes of witnesses are significantly bigger.

Full vs Semi §

python full_vs_semi.py cache_1_000_000/semi_stateless_witnesses.db.stats.1.csv

Full Witness sizes are adjusted for missing codes components.

As we see from this chart, using the semi-stateless approach saves quite a lot of data if we compare it to the full stateless approach.

Conclusion §

The verified mean is 0.037598 MiB per recorded witness entry for the sample described above. The original “around 0.4 MB” conclusion was incorrect. The comparison with full witnesses suggests that keeping a trie cache can reduce witness data; the earlier experiment provides the full-witness context.

This remains a proposal for accelerating initial sync. The data-volume comparison is against fully stateless witnesses; sync speed still needs to be measured. The original final sentence said “fully stateful” where the comparison called for “fully stateless,” also corrected on September 5, 2026.