I started go-ethereum on mainnet and left it alone. It found peers, opened two HTTP servers, wrote itself a secret key, and synced nothing at all. Thirty-five seconds in it logged a warning, then repeated it every five minutes:
WARN [09-23|02:59:59.915] Beacon client online, but no consensus updates received in a while. Please fix your beacon client to follow the chain!
There was no beacon client. That is the whole first lesson: since the Merge, one program is not a node. You run an execution client and a consensus client, they talk to each other over a separate authenticated port, and the execution client will sit at the genesis block forever until the consensus client tells it where the chain head is. Geth's own documentation says so plainly — "Geth cannot sync until the connected consensus client is synced. This is because Geth needs a target head to sync to." Reth puts it in an exclamation mark: "it will not start syncing until you spin up the consensus layer!"
So I ran the pair properly and measured it. Everything below comes from go-ethereum 1.17.5-stable (commit 9621c6ad), reth 2.6.0 (73a3a008) and Lighthouse 8.2.2 on an Apple M3 Pro, 12 logical CPUs, 36 GiB of memory, Darwin 25.5.0 on arm64, with Node.js 22.17.0 driving the probes. The clients were downloaded to a temporary directory and deleted afterwards; nothing on this machine's toolchain was changed.
One node, four doors
A running node opens more listening ports than most tutorials mention, and they have very different trust properties.
Note the asymmetry in the top panel. The Engine API on 8551 is authenticated with a shared secret. The peer-to-peer ports are public because they have to be. The JSON-RPC port your application uses — the one carrying every balance read, every eth_call, every log query — has no authentication at all. Its only defence is that it binds to 127.0.0.1 by default, and the moment you put the node on a different machine from the application, you are the one who has to replace that defence with something.
The handshake, and what actually crosses it
Running the pair is three commands. The secret has to exist before either client starts, and both must be given the same file.
openssl rand -hex 32 | tr -d '\n' > /srv/jwt.hex
chmod 600 /srv/jwt.hex
geth --datadir /srv/geth --authrpc.jwtsecret /srv/jwt.hex --http
lighthouse bn --network mainnet --datadir /srv/lighthouse \
--execution-endpoint http://127.0.0.1:8551 \
--execution-jwt /srv/jwt.hex \
--checkpoint-sync-url https://mainnet.checkpoint.sigp.io
That is the shape of what I ran, on the default ports. The measured run used non-default ports and temporary data directories so it would not collide with anything else on this machine, and put a counting reverse proxy between the two clients so I could see the traffic without instrumenting either one.
Checkpoint sync is fast and it is now the default path. Lighthouse's book states that "Since v4.6.0, checkpoint sync is required by default and genesis sync will no longer work without the use of --allow-insecure-genesis-sync". It took 148 seconds to download and load the finalized state from Sigma Prime's endpoint, and 453 seconds from the start of checkpoint sync to the first Synced log line.
The handshake itself is short. One engine_exchangeCapabilities, a pair of engine_getClientVersionV1 exchanges, then a engine_forkchoiceUpdatedV3 that took 1,158 ms — by far the slowest single call in the run, because it is the one that tells the execution client to start syncing. Every subsequent forkchoice update had a median latency of 0.5 ms.
Then something I did not expect. Over 1,065 seconds the proxy counted 416 requests, and 277 of them (67%) were eth_syncing — not an engine_ method at all. The consensus client spends most of its conversation asking the execution client whether it has caught up yet. The engine_ namespace accounted for only a third of the traffic on the port named after it.
That is worth knowing if you ever debug this link. The port is authenticated, but the method mix is dominated by a question about sync state, and the two clients are in a tight polling loop, not an occasional handshake.
Your node will answer long before it is ready
This is the part that will actually break an application, and it is easy to reproduce.
While Lighthouse was fully synced and feeding geth a new head every twelve seconds, I pointed the same health checks at geth that a backend would use. Against a hosted endpoint the chain head was block 26,036,864. Against my own node:
| What the application asks | What the syncing node answered |
|---|---|
eth_blockNumber |
0 |
eth_getBlockByNumber("latest") timestamp |
1970-01-01T00:00:00Z |
eth_getCode for WETH9 |
0x — no code |
eth_syncing |
an object with every progress field at zero |
The node was up. It answered every request promptly. It returned the genesis block as latest and told the application that the most widely held ERC-20 contract on Ethereum does not exist.
eth_syncing is the method most people reach for, and it is a genuine trap. It returns false when the node is caught up and an object while it is syncing, so if (!syncing) reads as healthy only at the end — that part works. But a node that has not started syncing returns an object whose fields are all zero, which has exactly the same shape as one that is nearly finished. The number that would tell you the difference, highestBlock, was also 0.
The fix is not clever, it is just two checks instead of one: require eth_syncing to be literally false, and then require the head it is serving to be recent.
// examples/articles/how-do-i-run-my-own-ethereum-node-and-connect-an-app-to-it/app/node-client.mjs
async function assertUsable(c, { maxHeadAgeSeconds = 120 } = {}) {
const syncing = await c.request({ method: 'eth_syncing' });
if (syncing !== false) {
return { usable: false, reason: 'eth_syncing did not return false', syncing };
}
const head = await c.getBlock({ blockTag: 'latest' });
const ageSeconds = Math.floor(Date.now() / 1000) - Number(head.timestamp);
if (ageSeconds > maxHeadAgeSeconds) {
return { usable: false, reason: `head is ${ageSeconds}s old`, blockNumber: head.number, ageSeconds };
}
return { usable: true, blockNumber: head.number, ageSeconds };
}
Run against a healthy local node that prints usable true and a head six seconds old; run with the local node's port closed and viem's fallback transport moves to the hosted endpoint and prints a mainnet head instead. That fallback demonstration also shows the thing to be careful about: the chain ID in the output changed from 1337 to 1. A fallback is a real switch to a different backend, and nothing stops it being a different chain if you configure it carelessly.
The snippet uses viem; the same shape exists in Ethers.js as FallbackProvider, with quorum and priority options instead. One detail about the transport. viem's fallback ranks transports by latency and stability by default, which will happily demote your own node in favour of a faster hosted endpoint — the opposite of why you self-hosted. Passing rank: false keeps the order you wrote.
What the RPC port exposes, and what it stopped exposing
I started both clients with plain --http and asked each namespace a representative question.
| Configuration | Namespaces that answered |
|---|---|
geth --http |
eth, net, web3, rpc |
reth node --http |
eth, net, web3 |
geth --http.api eth,net,web3,admin,debug,txpool,personal |
eth, net, web3, rpc, admin, debug, txpool |
reth node --http.api eth,net,web3,admin,debug,txpool,trace |
eth, net, web3, admin, debug, txpool, trace |
Two things in that table are worth pulling out.
First, personal is gone. I asked for it explicitly and geth started anyway, silently dropping it; personal_listAccounts returns -32601 the method personal_listAccounts does not exist/is not available. It was removed in go-ethereum v1.14.12, published 2024-11-19, whose release note says it "has not been accessible by default for nearly two years". This matters because the canonical horror story about exposed Ethereum RPC — attackers scanning port 8545, calling personal_unlockAccount and draining the account — describes an attack surface a current geth does not have. Geth's own documentation page for the namespace is still live, and still describes personal as having been "deprecated in favour of using Clef as an external signer and account manager" — deprecated, not removed, roughly two years after the code was deleted.
Second, engine cannot be moved onto the public port by asking. I started geth with --http.api eth,engine; it started, served eth and rpc, and returned -32601 for engine_getClientVersionV1. The execution-apis spec requires this: the execution client "MUST expose the authenticated Engine API at a port independent from existing JSON-RPC API". Worth noting that geth's RPC documentation says a non-whitelisted namespace produces -32602; the code returns -32601. The docs are wrong on that detail.
The authenticated port behaves the way the spec describes, and both clients implement the same window independently. I sent eight token variants to each:
| Token | geth 1.17.5 | reth 2.6.0 |
|---|---|---|
| none | 401 missing token |
401 Authorization header is missing or invalid |
iat = now |
200 | 200 |
iat 30s in the past |
200 | 200 |
iat 61s in the past |
401 stale token |
401 IAT (issued-at) claim is not within ±60 seconds from the current time |
iat 61s in the future |
401 future token |
401 same message |
no iat claim |
401 missing issued-at |
401 JWT decoding error: ... missing field \iat`` |
| valid signature, wrong secret | 401 signature is invalid |
401 provided signature is invalid |
| signature stripped | 401 signature is invalid |
401 provided signature is invalid |
Six of eight rejected on both, and the ±60 second boundary is exact on both sides. The spec only says clients "SHOULD only accept iat timestamps which are within +-60 seconds from the current time" — it is a SHOULD, not a MUST, and two independent implementations landed on it precisely. If your node pair stops talking after a suspend-resume or a drifting clock, this is the first thing to check.
The secret files differ more than you would expect. Geth writes <datadir>/geth/jwtsecret, 66 bytes, 0x-prefixed, mode 0600. Reth writes <datadir>/jwt.hex, 64 bytes, no prefix, mode 0644 — world-readable under this machine's default umask. Different filename, different format, different permissions, same purpose. Anyone who has wired a pair together by copying a path from one client's docs into the other's flag has met this.
vhosts is not a firewall
Geth checks the Host header of incoming requests, and this is widely misread as a network control. It is not one.
Host: header sent |
geth (default --http.vhosts localhost) |
reth 2.6.0 |
|---|---|---|
localhost |
200 | 200 |
127.0.0.1 |
200 | 200 |
192.168.1.50 |
200 | 200 |
node.internal |
403 invalid host specified |
200 |
evil.example.com |
403 invalid host specified |
200 |
Geth rejects hostnames it was not told about, and serves any bare IP address. That is deliberate and correct for what the check is for — it exists to stop DNS rebinding, where a malicious page resolves a name it controls to your node's address. A comment in geth's source says exactly that: the check "prevents attacks like DNS rebinding", and "Requests using ip address directly are not affected". Rebinding needs a name; an attacker who already knows your IP does not.
So if you bind to 0.0.0.0 and assume the default vhosts value protects you, it does not. Reth makes this less ambiguous by having no such flag at all: it has no Host-header check, so CORS is the only browser-facing control it offers. Neither client is a substitute for a firewall rule, and geth's security page says as much: "Block all traffic to 8545 ... except for traffic from explicitly defined trusted machines."
admin is not a namespace, it is a reconfiguration primitive
This is the one that changed how I think about the namespace list.
I started geth with a deliberately careless split: --http.api eth on the HTTP port, and --ws.api admin,eth on the WebSocket port. The HTTP port served eth and rpc, and engine_getClientVersionV1 was absent, as expected.
Then, over the unauthenticated WebSocket, I called admin_stopHTTP and admin_startHTTP with a module list of my own choosing. Both returned true. Afterwards the same HTTP port served admin, debug, engine, eth and rpc, and engine_exchangeCapabilities answered with no Authorization header at all, listing 38 engine methods.
The JWT requirement on the Engine API is a property of how the auth server was started. admin_startHTTP starts a server from the full API set with a caller-supplied module list and no secret, so anything that can reach admin can re-open the node's RPC surface on its own terms. Exposing admin is not "one more namespace"; it is handing over the configuration.
debug has a related problem you cannot design around. It is all-or-nothing: enabling debug_traceTransaction, which is genuinely useful and the main reason people self-host, also enables debug_setHead, which rewinds the chain. A 2020 feature request to split them is still open.
What your own node gives an application that a hosted endpoint does not
The usual arguments for self-hosting are cost, privacy and rate limits — I have costed a small application's RPC bill in detail elsewhere. There is a fourth argument that is more concrete: stateful methods.
eth_newFilter installs state, in the spec's own words, "in the server". A hosted endpoint is many servers behind one hostname. The filter ID your client is holding is only meaningful if the next request reaches the same machine.
I created a log filter and a block filter on nine endpoints, held them for 45 seconds, and polled again.
| Endpoint | eth_newFilter |
Still valid after 45s |
|---|---|---|
| local geth 1.17.5 | created | yes |
| local reth 2.6.0 | created | yes |
| PublicNode | created | yes |
| dRPC | HTTP 400 |
— |
| MEV Blocker | -32601 Method not found |
block filter created, then -32601 |
| Tenderly | HTTP 400 |
block filter created, then -32602 filter not found |
| 1RPC | -32600 Method eth_newFilter not allowed |
— |
| Flashbots Protect | timed out | — |
| LlamaRPC | HTTP 525 |
— |
The two self-hosted nodes and PublicNode behaved correctly. The interesting rows are Tenderly and MEV Blocker, because they did not refuse cleanly. Both accepted eth_newBlockFilter and handed back an ID, and both failed when that ID was presented again 45 seconds later — one with "filter not found", one by claiming the method does not exist. An endpoint that refuses outright is easy to code against. An endpoint that issues a handle and then loses it produces a bug that only shows up under load.
1RPC's message is the most honest of the group: the method is not allowed, which is a policy, not a capability gap.
WebSocket subscriptions were better supported. eth_subscribe("newHeads") delivered a head from local reth in 204 ms, PublicNode in 1,108 ms, Tenderly in 2,643 ms, dRPC in 4,368 ms and local geth in 4,677 ms — the local numbers reflect a 12-second dev-chain block time, not latency. viem documents the consequence of this patchwork directly: if the provider does not support filters, watchEvent falls back to getLogs. Your own node is the one place you can rely on the stateful methods being there.
Filter lifetime is short everywhere, including on your own node. Both geth and reth expire an unpolled filter after five minutes, and geth exposes no flag to change it.
Hardware, disk and time
I could not sync mainnet on this machine, so I am not going to pretend otherwise: these figures are from the clients' own documentation, and the spread between them is the actual finding.
| Source | Full node | Archive | Dated |
|---|---|---|---|
| reth storage docs | 1.02 TB | 2.31 TB | block 24,396,823 (2026-02-06) |
| Erigon | 419 GB | 2.03 TB | 2026-07-19 |
| Besu | ~1.14 TB | — | page updated 2026-09-01 |
| geth hardware page | >650 GB, +14 GB/week | >12 TB | self-dated September 2022 |
| geth archive page | — | ~2 TB path-based, >20 TB hash-based | inspection at block 22,788,923 |
A default-pruned full node in 2026 is somewhere between 0.4 and 1.2 TB depending on client and storage layout, plus the consensus client. The archive figures are the ones to be careful with: geth's hardware page says over 12 TB and its own archive page says about 2 TB, because the first describes the old hash-based layout and the second the path-based one. They are not measuring the same thing.
RAM guidance spans a factor of four across primary sources — ethereum.org suggests 4–8 GB, reth says 8 GB+ for a full node, geth says 16 GB, and Lighthouse's page asks for 32 GB for a combined setup while stamping itself "Last update: April 2023". Buy for the high end of that range if you intend to leave it running.
Sync time is worse documented still. The only well-specified figure I found is Besu's ~12–16 hours for a snap sync, attributed to a named release burn-in on a named AWS instance type. Erigon publishes estimates and explicitly says they are "not measured by CI". Geth publishes no full-node sync duration at all. Reth's frequently quoted "1–3 hours" is the network-bound stages only, not an end-to-end sync.
My own partial run is a data point about the shape, not the duration: geth's reverse beacon-header phase was reporting an ETA of 31 hours for headers alone when it stalled, and it stalled because it could not hold execution-layer peers on this residential connection — a maximum of 2 peers across 79 samples, and zero for 62 of them. Lighthouse, on the same machine at the same time, held 16 to 21 consensus-layer peers without trouble. If a home node is going to disappoint you, peering on the execution layer is a likelier cause than disk.
Two practical notes that cost me time. --syncmode fast and --syncmode light are both gone; geth 1.17.5 exits with unknown sync mode "fast", want "full" or "snap". And geth's official build store has no macOS binary newer than 1.15.6, last modified 2025-03-25, while it publishes Linux builds of 1.17.5 — on a Mac you are building from source or using Homebrew, which does carry 1.17.5.
What I would actually do
Run the pair on a machine that is not your laptop, with a wired connection and a 2 TB NVMe drive, and check the execution client's peer count before blaming anything else.
Leave --http.api alone. The default of eth, net and web3 is what an application needs. Add debug only if you are actually tracing, and understand that you are adding debug_setHead at the same time. Never add admin to a reachable port — it is not a namespace, it is the ability to rewrite the other namespaces.
Keep the JSON-RPC port on loopback. If the application lives elsewhere, put it behind something that authenticates, and do not let a default vhosts value stand in for that. Generate the JWT secret yourself with openssl rand -hex 32, point both clients at the same file, and check its permissions after reth writes one.
In the application, treat your node as a dependency that can be up and useless at the same time. Require eth_syncing === false and a recent head before you serve a read from it, and put a hosted endpoint behind it as an explicit, unranked fallback. That combination gets you the stateful methods and the privacy of your own node without the failure mode where a syncing node quietly reports that WETH has no code.
None of this is the right first step for local development. For building and testing against mainnet state you want a local fork with Anvil, which starts in a second and costs nothing, or a testnet. Run your own mainnet node when you need mainnet, continuously.
If you mostly want the reads and none of the operations, a hosted endpoint is a reasonable answer and I would not argue with it. The reason to run your own is that you need something a shared endpoint will not give you: traces, unrestricted log ranges, filters that survive, or the privacy of not telling a third party which addresses you care about.
What this does not establish
I did not complete a mainnet sync, so nothing here measures how long one takes, how large the resulting database is, or how a fully synced node performs under load. The disk, RAM and sync-time figures are quoted from client documentation with their dates attached, and several of those pages are years old.
The filter and subscription comparison ran against keyless public endpoints, which are not the paid tiers of the same providers. A paid plan with sticky sessions may well keep filter IDs alive; this measures what is reachable without an account, on one afternoon, from one IP address. Two endpoints were unreachable for reasons that had nothing to do with filters.
The local side of that comparison used single-node development chains, because the question was whether the RPC surface supports stateful methods, not what data the chain holds. The namespace, Host-header and JWT results are from the client binaries themselves and do transfer.
The admin_startHTTP result is a measurement of documented behaviour in a configuration nobody should run, not a vulnerability report. It requires an exposed admin namespace, which every client's documentation already tells you not to have.