Your subscription emits a transaction hash. You immediately request the transaction and get null. Or a transaction lands in a block, but its hash never appeared in your pending feed.

Those symptoms are different. The first proves that your subscription delivered a particular hash but a later lookup could not resolve it. The second only proves that you did not record a pending notification. Neither, by itself, identifies replacement, eviction or private submission.

This is the diagnostic companion to accessing the Ethereum mempool. The aim is to collect enough evidence to separate a broken consumer from the limits of a node-local feed, without inventing a cause for every missing hash.

Missing pending transaction diagnosis: separate a never-seen hash from a delivered hash with a null lookup, check connection health and canonical receipts, and only infer replacement when sender and nonce evidence exists

There is no global mempool feed

Each node maintains its own transaction pool. Its peers, admission rules, available capacity and current chain state affect what it stores. Two healthy nodes can have different pending sets.

A public subscription also cannot expose transactions that were never broadcast into public gossip. Flashbots Protect, for example, submits through a private path. Those transactions can become visible when included in a block without having appeared in your public pending feed.

Do not label an arbitrary missing hash “private.” A public transaction can reach another node but miss yours before inclusion. A client-side outage can also produce the same observation. Private submission is one possible explanation for a never-seen transaction, not a fact derivable from silence.

Conversely, if the feed already delivered the hash, private routing cannot explain why you never received that hash. Investigate the subsequent lookup and pool state instead.

A subscription is not a durable queue

Geth’s pubsub documentation states that subscriptions report current events, not past events, and are tied to their connection. Creating a new subscription does not replay a gap from the previous one.

newPendingTransactions is not an inventory of every transaction in every txpool category either. In Geth, pending notifications concern transactions entering the pending/executable stream. Queued future-nonce transactions are a separate pool view; providers may additionally offer nonstandard full-transaction subscription options or filtering.

Record the exact client or service, method parameters, subscription acknowledgement, connection start and close times. If the provider silently changes the backend for HTTP lookups, a hash delivered by a WebSocket backend may be unavailable on the HTTP backend you query next. Prefer the same node when diagnosing this, and ask the provider what its routing guarantees actually are.

Why a delivered hash can return null

Replacement uses sender and nonce, not the old hash

Different signed transactions can use the same sender and nonce. A node may replace the older transaction when the new one satisfies its policy. In Geth’s legacy transaction pool, replacement rules for EIP-1559 transactions consider both fee cap and tip cap; “10% more tip always replaces it” is not a sufficient rule. Client configuration and transaction type matter.

A replacement has a different hash. If you previously decoded the old transaction, retain its sender and nonce so you can look for a competing hash. If all you ever received was an unresolved hash, you cannot recover the sender or nonce from the hash alone.

Finding another transaction with the same sender and nonce is evidence of a competing transaction. Finding that other hash in a canonical block proves which one consumed the nonce on that branch. Neither observation requires calling it a different searcher: it may be your own retry, wallet replacement or another copy of the same target transaction.

Pool removal has several causes

Transactions can leave a node’s pool through replacement, inclusion, capacity policy, expiry or changing validity. A full pool can be relevant, but txpool_status reporting many entries does not prove that a particular hash was evicted.

Do not hardcode a universal capacity threshold. Geth has distinct pending and queued limits, per-account rules and transaction-type-specific handling. Other clients and managed providers have their own policies. Use the actual node configuration and logs when you need a causal diagnosis.

A later retry may resolve a backend difference

A short retry can distinguish a persistent miss from a transient one. If the same hash becomes available, record “resolved after retry,” not “Geth had not indexed it yet.” Provider routing, state changes and delivery timing can produce similar observations.

Keep retries bounded. An unbounded hydration loop can turn a burst of unresolved hashes into a growing queue that prevents your bot from reading new work. Also check your library’s request cache: repeatedly serving a cached null is not a fresh node lookup.

Mined does not normally mean unqueryable

eth_getTransactionByHash can return both pending and mined transactions. A mined result has a block hash and block number. Mining alone is not a reason for a recent transaction to become permanently unavailable.

Historical lookup support depends on client indexing and provider retention. Those policies are not the same as archive-state access. Do not assume you need an archive plan merely to look up a recent mined transaction.

A receipt is evidence of execution in a block, but it is not automatically final. To check whether a stored receipt still refers to the canonical branch, fetch the block by the receipt’s number and compare its hash with receipt.blockHash. Fetching the old block by hash only proves the node can still return that block; it does not prove the block remains canonical.

A finite read-only diagnostic

The following tool looks up one known hash. It retries null transaction lookups three times, checks a receipt’s block against the canonical block at that height, and optionally examines Geth’s current pool for a known sender/nonce pair.

It does not manufacture replacement or eviction labels when the necessary evidence is missing. txpool_content can be expensive and is often disabled on public endpoints, so it is opt-in.

Install ethers v6 and save the code as diagnose-pending.mjs:

npm install ethers@6
import { JsonRpcProvider, WebSocketProvider, isAddress } from "ethers";

const [hash, originalSender, originalNonce] = process.argv.slice(2);

if (!/^0x[0-9a-fA-F]{64}$/.test(hash || "")) {
  throw new Error(
    "Usage: node diagnose-pending.mjs HASH [ORIGINAL_SENDER ORIGINAL_NONCE]"
  );
}

if ((originalSender === undefined) !== (originalNonce === undefined)) {
  throw new Error("Supply both original sender and nonce, or neither");
}

if (originalSender && !isAddress(originalSender)) {
  throw new Error("Invalid original sender");
}

const nonce = originalNonce === undefined ? null : BigInt(originalNonce);

if (nonce !== null && nonce < 0n) {
  throw new Error("Nonce must be nonnegative");
}

const url = process.env.RPC_URL || "ws://eth:8545";
const protocol = new URL(url).protocol;

if (!["http:", "https:", "ws:", "wss:"].includes(protocol)) {
  throw new Error("Unsupported RPC_URL");
}

const provider = protocol.startsWith("ws")
  ? new WebSocketProvider(url)
  : new JsonRpcProvider(url, undefined, { batchMaxCount: 1, cacheTimeout: -1 });

try {
  const report = {
    hash,
    chainId: await provider.send("eth_chainId", []),
    observation: "unresolved",
    lookupAttempts: 0,
  };

  let tx = null;

  // Retry briefly for propagation lag, not indefinitely for a hash that vanished.
  for (let attempt = 0; attempt < 3; attempt++) {
    report.lookupAttempts++;
    tx = await provider.send("eth_getTransactionByHash", [hash]);

    if (tx) {
      break;
    }

    if (attempt < 2) {
      await new Promise((resolve) => setTimeout(resolve, 200));
    }
  }

  // A null result leaves the cause unresolved; it does not prove a replacement.
  if (tx) {
    report.observation = tx.blockHash
      ? "mined_transaction_observed"
      : "pending_transaction_observed";

    report.transaction = {
      from: tx.from,
      nonce: tx.nonce,
      blockHash: tx.blockHash,
    };
  }

  const receipt = await provider.send("eth_getTransactionReceipt", [hash]);

  if (receipt) {
    // A receipt on an orphaned branch is not canonical inclusion.
    const block = await provider.send("eth_getBlockByNumber", [
      receipt.blockNumber,
      false,
    ]);

    report.receipt = {
      blockNumber: receipt.blockNumber,
      blockHash: receipt.blockHash,
      status: receipt.status,
      matchesCanonicalBlock: block ? block.hash === receipt.blockHash : null,
    };
  }

  if (process.env.CHECK_TXPOOL === "1") {
    try {
      const pool = await provider.send("txpool_content", []);
      report.poolMatches = [];

      for (const category of ["pending", "queued"]) {
        for (const transactions of Object.values(pool[category] || {})) {
          for (const entry of Object.values(transactions)) {
            const sameHash = entry.hash.toLowerCase() === hash.toLowerCase();
            // Replacements share the sender and nonce, but have a different hash.
            const sameNonce =
              originalSender &&
              entry.from.toLowerCase() === originalSender.toLowerCase() &&
              BigInt(entry.nonce) === nonce;

            if (sameHash || sameNonce) {
              report.poolMatches.push({
                category,
                hash: entry.hash,
                from: entry.from,
                nonce: entry.nonce,
                sameHash,
              });
            }
          }
        }
      }
    } catch (error) {
      report.poolLookupError = error.shortMessage || error.message;
    }
  }

  console.log(JSON.stringify(report, null, 2));
} catch (error) {
  console.error(error.shortMessage || error.message);
  process.exitCode = 1;
} finally {
  await provider.destroy();
}

With RPC_URL pointing to the node you are investigating and TX_HASH set to the observed hash:

node diagnose-pending.mjs "$TX_HASH"

If you captured the original sender and nonce before it disappeared, set SENDER and NONCE and enable the optional pool check:

CHECK_TXPOOL=1 node diagnose-pending.mjs "$TX_HASH" "$SENDER" "$NONCE"

The report describes point-in-time observations. A null transaction and null receipt still leave the cause unresolved. An unsupported txpool_content call is printed as poolLookupError, not converted into an empty pool.

Backpressure can create gaps in your own consumer

Geth’s documentation describes a notification buffer limit of 10,000 and a connection close when the subscriber cannot keep up. That documented behavior should not be treated as a universal limit for every provider or client version.

Keep the socket reader cheap. Enqueue work into a bounded queue, then hydrate with a limited number of workers. An unbounded array simply moves the eventual failure from the node’s buffer to your process memory. Record queue depth, active work, dropped samples and socket close reasons.

A diagnostic that launches one untracked asynchronous request for every hash can overload its endpoint and then mistake the resulting failures for missing mempool coverage. Rate-limit your own diagnostic workload and keep transport errors separate from genuine null results.

Reconnect and reorg recovery are different

After a disconnect, a fresh txpool_content snapshot can recover transactions still present on that node. It cannot recover everything that arrived and disappeared during the gap. Block data can recover transactions that were included, but not public transactions that were replaced or dropped without inclusion.

After a reorg, a transaction from an orphaned block can re-enter the pending stream if it is eligible. The same hash may therefore appear again. Deduplication should prevent duplicate work without permanently suppressing a transaction whose status changed from included back to pending.

Track an observation history rather than a single irreversible seen bit. Pending, included on a particular block hash, orphaned and finalized are different states. A block-height comparison alone cannot distinguish two branches at the same height.

Measure coverage with an honest denominator

A single node cannot tell you the set of all transactions ever gossiped anywhere during a time window. The transactions in later blocks are also not a pure public-mempool denominator, because some arrived through private paths.

Useful measurements are narrower: subscription uptime, the fraction of delivered hashes resolved within a deadline, lookup error rates, and the overlap between two named node feeds over the same interval. Record when collection started and whether snapshots include transactions that were already pending before that time.

An overlap of 90% does not, by itself, prove a healthy drain rate or explain the remaining 10%. It is a measurement to investigate, not a diagnosis.

BLAZED.sh keeps the application-to-node path local on Ethereum mainnet. That can reduce hydration round trips, but it does not create a global mempool or expose private builder submissions. If the missing transaction was meant to be part of a private backrun, continue with bundle inclusion troubleshooting rather than assuming a public feed should have shown it.