DEV Community

SwiftNodes
SwiftNodes

Posted on • Originally published at swiftnodes.io

Is Your RPC Node Actually at the Chain Tip? How to Catch a Stale Endpoint

Here's a failure mode that's quietly common and genuinely nasty: an RPC endpoint that responds perfectly — 200 OK, valid JSON, every call works — but is serving data from hours or days ago because the node behind it fell behind the chain and never caught up. Your app reads "confirmed" balances that are stale, submits transactions against an old nonce, and shows users numbers that don't match the explorer. Nothing errors. This bites both people running their own node ("is it synced yet?") and people consuming a provider ("is this endpoint at the tip, or lagging?"). The tools to check are simple — but the most obvious one has a trap. Here's how to actually know.

The trap: eth_syncing returns false for two opposite states

The instinct is to call eth_syncing. When a node is actively catching up, it returns a rich object:

{ "startingBlock": "0x...", "currentBlock": "0x1878b4e", "highestBlock": "0x1878cea" }
Enter fullscreen mode Exit fullscreen mode

When a node is fully synced, it returns:

{ "jsonrpc": "2.0", "id": 1, "result": false }
Enter fullscreen mode Exit fullscreen mode

So far so good — false means "done syncing," right? Not exactly. eth_syncing returns false in two opposite situations:

  1. The node is fully synced and at the tip. ✅
  2. The node isn't syncing at all — freshly started, stalled, or waiting on its consensus client — so it has nothing to report. ❌

A node that's stuck at a block from last week, with its execution head frozen, can happily return eth_syncing: false. If you treat false as "healthy," you'll trust a stale node. eth_syncing tells you whether a sync is in progress — not whether you're at the tip. You need a second, independent check.

The real check: compare block height to a reference

The reliable signal is: does this node's latest block match where the chain actually is right now? You can't know "where the chain is" from the node you're suspicious of, so compare against something independent.

Option A — compare to a reference endpoint. Ask your node for its head and ask a known-good endpoint (a public node, a block explorer API, another provider) for theirs, and diff them:

const [mine, ref] = await Promise.all([
  myClient.getBlockNumber(),
  refClient.getBlockNumber(),
]);
const lag = Number(ref - mine);
if (lag > 5) console.warn(`node is ${lag} blocks behind the reference`);
Enter fullscreen mode Exit fullscreen mode

A healthy node is within a block or two of the reference (accounting for propagation). Tens or thousands of blocks behind means it's lagging or stuck.

Option B — check the latest block's timestamp against wall-clock. This needs no second endpoint, which makes it great for a self-contained health check. Every block carries a timestamp; if the newest block is much older than "now," the node isn't keeping up:

const block = await client.getBlock();               // latest
const ageSeconds = Math.floor(Date.now() / 1000) - Number(block.timestamp);
if (ageSeconds > 60) console.warn(`newest block is ${ageSeconds}s old — node is behind`);
Enter fullscreen mode Exit fullscreen mode

Pick a threshold a few multiples of the chain's block time (≈60s on Ethereum's ~12s blocks; tighter on fast chains). This is the single most useful "is it at the tip?" check because it catches the frozen-but-responsive node that eth_syncing: false hides. It's exactly how you'd catch a provider quietly serving stale data.

Why "it responds" is not "it's synced"

This is the core lesson, and it's worth stating plainly: a liveness check is not a freshness check. Health checks that only confirm the endpoint answers (eth_blockNumber returned something, HTTP 200) will keep a stalled node in rotation, because a stalled node still answers — it just answers with an old number. If you run any kind of failover or load balancing across endpoints, your health check has to assert the head is advancing and recent, not merely that a response came back. The cheap version: poll eth_blockNumber twice a few seconds apart and confirm it moved.

const a = await client.getBlockNumber();
await sleep(4000);
const b = await client.getBlockNumber();
if (b <= a) console.warn("head not advancing — node may be stuck");
Enter fullscreen mode Exit fullscreen mode

Sanity-check finality too, not just height

Being at the tip isn't the whole story on chains with a separate finality signal. A node can be at the head block yet that block isn't final (and could be reorged), or — on some setups — the head advances while finalization stalls. Where it matters (exchanges, settlement), also read the finalized block tag and confirm it's recent and progressing:

const finalized = await client.getBlock({ blockTag: "finalized" });
Enter fullscreen mode Exit fullscreen mode

If the head is moving but finalized is stuck far behind, something is wrong with the consensus side even though eth_syncing and block height look fine. See soft vs. hard finality for why the two can diverge, and handling chain reorgs for keying data safely on the head.

The Solana equivalents

Non-EVM chains have their own versions of the same idea. On Solana:

  • getHealth — returns "ok" when the node is within a small slot distance of the cluster tip; returns an error (with how many slots behind) when it's lagging. This is a real freshness check, unlike eth_syncing.
  • getSlot vs a reference — compare your node's current slot to a known-good endpoint's, same as the block-height diff above.
  • solana catchup --our-localhost — from the CLI, tells you exactly how far behind the cluster your node is and whether it's closing the gap.

The principle carries across every chain: confirm the head is recent and advancing against an independent reference — don't trust a single "healthy" boolean.

The short version

eth_syncing returning false does not prove a node is at the tip — it returns false both when fully synced and when not syncing at all (stalled/stuck). To really know: compare the node's latest block to an independent reference (another endpoint or explorer), and/or check the newest block's timestamp against wall-clock (a block much older than "now" = the node is behind). Remember that a liveness check is not a freshness check — a stalled node still answers eth_blockNumber, so any health check driving failover must assert the head is advancing and recent. Sanity-check the finalized tag where finality matters, and use getHealth / catchup as the Solana equivalents. This is how you catch both a self-hosted node that quietly fell behind and a provider serving stale data.

Want endpoints that are actually at the tip — health-checked and load-balanced so a lagging node gets pulled from rotation? A flat-rate Ethereum RPC endpoint, Solana RPC, and 75+ other chains under one key, over HTTP and WebSocket. Grab a free key and point your stack at:

https://rpc.swiftnodes.io/rpc/eth?key=YOUR_API_KEY
Enter fullscreen mode Exit fullscreen mode

Originally published on the SwiftNodes blog. SwiftNodes provides flat-rate multi-chain RPC endpoints — HTTP + WebSocket, 75+ chains, no per-request metering. Grab a free key.

Top comments (0)