Most applications do not need an archive node. The question is worth asking anyway, because the way a pruned node refuses is not always a refusal.
I probed eight keyless public Ethereum endpoints with the same 75 queries at nine depths, from the chain head back to block 1,000,000. Four of them answered historical state everywhere. Two cut off. One of the two returned an error for some queries and an empty result for others, with a success status attached.
That second failure mode is the reason to think about this before you deploy, not after.
The 128-block number is out of date
Ask what a full node keeps and you will be told 128 blocks. ethereum.org says full nodes "only keep a local copy of relatively recent data (typically the most recent 128 blocks)." Alchemy's archive node overview says full nodes store state "up to the last 128 blocks." Geth's own sync modes page is where the number comes from: "Only the most recent 128 block states are stored in a full node," which it helpfully translates as "about 25.6 minutes of history."
No major client actually defaults to that today.
Geth's path-based state scheme retains state history for --history.state blocks, and the flag's help text gives the default as 90,000. That is not a documentation artifact: in the source, the flag reads ethconfig.Defaults.StateHistory, which resolves through triedb/pathdb to params.FullImmutabilityThreshold, a constant equal to 90000. Reth's pruning documentation defines a full node as one that "keeps current state and a 10,064-block historical state window." Erigon's pruning modes make archive an explicit choice and default to a full node holding "the last 262,144 blocks," the window EIP-8252 defines as REORG_RETENTION_WINDOW.
So three clients, three defaults, spread across a factor of 26. The 128-block figure describes an in-memory trie window in the older hash-based scheme, and it survives in secondary documentation long after the clients moved on.
Documented windows are from each client's own documentation. Measured points are the deepest block each endpoint still answered eth_getBalance for, found by bisection on 2026-09-22. Logarithmic axis.
What I measured
Every probe used frozen anchors: one head block captured once, a fixed ladder of depths, and one transaction hash per depth, all reused by every endpoint. The head was taken 64 blocks behind the live tip so no probe raced a reorg.
The endpoints were PublicNode, dRPC, Tenderly, MEV Blocker, xrpc.cl, Flashbots Protect, Cloudflare and 1RPC — all keyless, all reachable without an account. Five of them name their client when asked: PublicNode reports reth/v2.5.2, Flashbots Protect reth/v1.11.2, and xrpc.cl Geth/v1.17.5-stable. Cloudflare and 1RPC were effectively unusable during the run, the first answering -32603 Internal error to almost everything and the second exhausting its unpublished keyless quota, so neither contributes a retention result.
Three controls decide whether the rest means anything.
Every endpoint that answered eth_call at a given block returned the same value — one distinct answer per depth across four or five responders. Across the nine depths, those values were all different from each other, so no endpoint was quietly replaying head state for a historical block. And at block 1,000,000, before USDC was deployed, the archive endpoints returned 0x for eth_call and eth_getCode while still returning a real balance for a genesis-funded account. That empty answer is the correct one, and it is the proof the node holds that state. A check that grades "did it return data" treats a working archive node and a broken one identically.
Not every historical query needs historical state
The useful distinction is not full versus archive. It is which of four things your query needs.
| What the query needs | Methods | Where it breaks |
|---|---|---|
| Nothing historical | eth_blockNumber, eth_chainId, eth_call at latest |
Nowhere |
| Historical state | eth_getBalance, eth_call, eth_getStorageAt, eth_getCode at an old block |
Beyond the client's state window |
| Historical data | eth_getBlockByNumber, eth_getTransactionByHash, eth_getTransactionReceipt, eth_getLogs |
Beyond the receipt, log and transaction-index windows, which are separate settings |
| Re-execution | debug_trace*, trace_* |
Needs the state before the block, so it inherits the state window |
| A Merkle proof | eth_getProof |
Needs historical trie nodes, which a flat-state archive may not keep |
Flashbots Protect showed all of these boundaries at once. It served eth_getBlockByNumber at every depth I asked for, including block 1,000,000. It served eth_getBalance and eth_getStorageAt down to about ten thousand blocks back and refused below that with -32603 state at block #21000001 is pruned. It answered eth_getLogs for pre-merge ranges with an error code I had not seen in production before: 4444 pruned history unavailable. And eth_getTransactionByHash for a real transaction in block 15,000,000 returned null — not an error, just nothing, because a reth full node disables the transaction lookup index entirely.
A node can hold the block and still be unable to find a transaction inside it by hash. Those are different indexes with different retention.
Proofs are their own axis. PublicNode, Tenderly and Flashbots Protect all refused eth_getProof with the identical message distance to target block exceeds maximum proof window, including at the chain head. Only dRPC and MEV Blocker produced proofs, and they produced them at every depth including block 1,000,000. Geth documents the same split directly: its archive node page says that in v1.16.x "historical Merkle proofs via eth_getProof are not supported" on a path-based archive, and that from v1.17.x they are supported only if history.trienode is configured. An archive node that answers eth_call at any block in history can still be unable to prove the answer.
Tracing barely exists on public infrastructure. Of eight endpoints, exactly one served debug_traceTransaction, and none served any trace_* method at any depth.
Two ways to be refused, and one of them is dangerous
The refusals split cleanly by cause, and the message tells you which you are dealing with.
PublicNode's cutoff is commercial. It refuses with HTTP 403 and -32602 Archive requests require a personal token. Get one at: https://www.allnodes.com/publicnode, and I measured that cutoff at 100 blocks behind head — twenty minutes of history — in three independent runs across several hours. That is a hundredth of its own client's default window. Flashbots Protect's cutoff is the client's: state at block #N is pruned, measured at 10,054 blocks behind head against reth's documented 10,064. Ten blocks apart, with the chain advancing during the bisection.
Both of those are honest. You get an error, your code throws, you notice.
The dangerous case is the one where a pruned node answers successfully with less data. In the probe run, at 100,000 blocks behind head, dRPC, Tenderly, MEV Blocker and xrpc.cl all returned 97 USDC Transfer logs for a single block. Flashbots Protect returned an empty array and a success status for the identical filter. At the same block, four endpoints returned a receipt containing seven logs while PublicNode, Flashbots Protect and Cloudflare returned null.
I could not re-run that observation. Two five-pass replications, ninety minutes and three hours later, found every archive endpoint that still answered returning 97 logs on every pass, and Flashbots Protect returning HTTP 504 gateway timeouts rather than an empty array. A timeout is not evidence that the earlier answer was wrong, and it is not evidence that it was right, so treat the empty-array reading as one observation with a stated timestamp rather than a characterisation of that service. The general shape is documented regardless: reth's pruning table marks eth_getFilterLogs and eth_getTransactionReceipt as unavailable once their segments are pruned, and "unavailable" for a log query means an empty result set, not an exception.
An indexer that treats an empty log window as "nothing happened in these blocks" will write that conclusion to your database and move on.
Pinning a fork is a real archive requirement
The most common reason an application developer needs historical state is not analytics. It is anvil --fork-url <endpoint> --fork-block-number <N>, which is how most teams write tests against real mainnet state.
Pinned forks need whatever state exists at the pin. Against dRPC, forks pinned 200 blocks and 50,000 blocks behind head both started and answered a contract call. Against PublicNode and Flashbots Protect, both pins failed to start with failed to create genesis. PublicNode forked fine when left unpinned at the head, which is the trap: the configuration that works in development breaks the moment someone pins a block for reproducibility.
There is a measurement hazard here worth repeating, because it cost me a wrong result first time. Foundry caches forked state under ~/.foundry/cache/rpc/<chain>/<block>/storage.json, and on anvil 1.5.1 that file is shared by every endpoint at the same block. My first run had all seven cases succeeding, including the pruned ones, because an earlier archive case had already filled the cache. Re-running with a fresh empty HOME per case produced the split above. I kept the contaminated pairing as a labelled control: with one shared cache directory, a dRPC fork at block head−50,000 followed by a PublicNode fork at the same block reports the PublicNode fork as working, and it is not.
If you have ever concluded that a free endpoint serves archive state because a fork test passed, check what warmed the cache.
What an archive node actually costs now
The other piece of folklore worth retiring is the 12 TB archive node.
That figure describes the hash-based state scheme. Geth's archive documentation, last edited February 2026, puts a path-based archive at "around 2 TB of storage" for flat state history, or "approximately 6.5 TB" if it also keeps historical trie data, against "exceed 20TB" for the hash-based equivalent — and it cuts sync time from "months" to "around 2 weeks." Erigon publishes measured numbers with a date attached: 2.03 TB for archive, 419.04 GB for its default full node, measured 2026-07-19 on v3.6. Reth documents at least 2.8 TB for archive and 1.2 TB for a full node.
Note that ethereum.org's hardware table, updated August 2026, still lists 12TB+ for a Geth or Nethermind archive and 2.2TB+ for Reth. Every client's own documentation now disagrees with the aggregated table in one direction or the other. Read the client you plan to run.
History pruning has moved separately. Since July 2025, every execution client supports dropping pre-merge block data under EIP-4444, which the Ethereum Foundation put at 300–500 GB saved. That was a client-level rollout, not a hard-fork activation, and it is why a node can serve you a block from 2020 and refuse its logs.
So do you need one?
You need an archive node if you compute historical balances or prices, reconstruct positions at past blocks, backfill an index from genesis, run debug_trace* on old transactions, produce Merkle proofs for arbitrary blocks, or pin fork tests to a block more than a few hours old.
You do not need one to run a wallet, submit transactions, read current state, follow the head, or serve a frontend. If your deepest read is latest and your logs come from a subscription or a recent range, a full node covers it, and the real cost of running an Ethereum app is mostly not storage.
The in-between case is where teams get caught. An indexer that backfills once and then follows the head needs archive access for the backfill and nothing afterwards. Renting that from a provider for a week is far cheaper than operating 2 TB of NVMe forever, and pinning fork tests needs archive access only for the blocks you pin.
Whatever you choose, test the boundary rather than trusting the tier name. Thirty lines of Node bisect it:
// examples/articles/do-i-need-an-ethereum-full-node-or-archive-node/snippets/state-depth.mjs
const head = Number((await rpc('eth_blockNumber', [])).result);
let lo = 0;
let hi = 1;
while (hi < head && (await hasState(head - hi))) hi *= 2; // expand until refused
if (hi >= head) {
console.log(`${url}\n archive: answered ${head} blocks back, at block 0`);
} else {
lo = hi / 2;
while (hi - lo > 1) {
const mid = Math.floor((lo + hi) / 2);
if (await hasState(head - mid)) lo = mid;
else hi = mid;
}
console.log(`${url}\n head ${head}\n deepest state read served: head-${lo} (block ${head - lo})\n refused at: head-${hi}`);
}
Run against PublicNode it prints deepest state read served: head-100; against MEV Blocker, archive: answered 26036665 blocks back, at block 0. It reads a genesis-funded account's balance, because that account has never had a zero balance, so an empty answer can never be the correct one and a refusal is unambiguous.
What this does not establish
These are eight public endpoints, not eight nodes. Every number in the measured half describes a service's policy at one moment, which is a mixture of a client configuration and a commercial gateway in front of it. PublicNode's 100-block cutoff is plainly a product decision rather than reth's behaviour, and I cannot see which is which from outside.
I did not run any of these clients. The documented windows are quotations from each project, not results I reproduced on my own hardware, and syncing an archive node to check them is not a laptop experiment. The probe ran once at full width, on 2026-09-22 between 20:47 and 21:20 UTC, from a single residential connection on an Apple M3 Pro; a different network, a different hour, or a different rate-limit state would move several cells.
The boundaries also move on their own. PublicNode's cutoff sat at 100 blocks behind its own head in every pass, but the block number underneath that offset advanced by hundreds between passes. Any retention window is a moving window, which means a query that worked when you wrote the test can fail on the same block a week later without anything in your code changing.