Logo
New RPC users get 35% off their first monthView the offer
OnFinality Learn
Integration & Development14 min read

eth_getTransactionReceipt Returns Null: Receipt Polling Patterns

A production-grade guide to interpreting null receipts, classifying transaction states, and building a bounded receipt poller that terminates correctly.

TL;DR

eth_getTransactionReceipt returns null whenever no receipt is available for a transaction hash, which includes the pending, unknown, and dropped-or-replaced states. The Ethereum JSON-RPC specification defines a receipt as existing only after inclusion in a block, so a null response is not an error and cannot be distinguished from a lagging node without a second query. A correct poller asks for both eth_getTransactionByHash and eth_getTransactionReceipt, classifies the pair, and terminates on a bounded interval, a maximum elapsed time, and a block-based deadline derived from the transaction nonce. This article covers the four transaction states, the authority of the receipt status field, reorg handling, and a runnable Node.js poller with a results table for measuring against your own endpoint.

What a Null Receipt Means Under the Ethereum JSON-RPC Specification

The Ethereum JSON-RPC specificationication defines eth_getTransactionReceipt as returning the receipt object for a transaction hash, or null when no receipt is available. A receipt is created only after a transaction has been included in a block and executed, so a null response is the expected result for any transaction that has not yet reached that point. This is documented protocol behavior, not a provider defect, and it applies equally to Ethereum mainnet and to EVM-compatible chains.

The companion method eth_getTransactionByHash returns the transaction object itself. While a transaction is only in the mempool, that object is returned with blockNumber set to null and no receipt exists. The JSON-RPC 2.0 specification governs the request and response envelope, so a null result is a successful response with a null value, not a JSON-RPC error. Treating it as an error is the most common cause of broken polling loops.

  • Receipt exists only after inclusion and execution.
  • Null is a valid, successful response value.
  • eth_getTransactionByHash returns the pending object with blockNumber null.
  • A JSON-RPC error is a different condition from a null result.

The Four Transaction States and the Method Pair That Distinguishes Them

A transaction hash observed by a client can be in one of four states: unknown, known and pending, included and executed, or dropped or replaced. Each state produces a distinct combination of return values from eth_getTransactionByHash and eth_getTransactionReceipt. Classifying the pair is the only reliable way to know which state you are in, because a null receipt alone is ambiguous.

In the unknown state, both methods return null. In the pending state, eth_getTransactionByHash returns a transaction object with blockNumber null while the receipt remains null. In the included and executed state, both methods return objects, and the receipt carries the status field. In the dropped or replaced state, the transaction object may vanish entirely and the receipt stays null forever. That final state is what makes naive polling loops run indefinitely.

  • Unknown hash: both methods return null.
  • Known and pending: transaction object with blockNumber null, receipt null.
  • Included and executed: both objects present, receipt carries status.
  • Dropped or replaced: transaction object may vanish, receipt remains null.

Why the Receipt Is the Authority for Success, Not the Transaction Object

The transaction object returned by eth_getTransactionByHash does not tell you whether execution succeeded. The status field lives in the receipt, where 0x1 indicates success and 0x0 indicates a revert. A mined transaction with status 0x0 still consumes gas, so the existence of a receipt means the transaction was included, not that it succeeded. Applications that treat receipt presence as success will silently accept failed transactions.

This distinction matters for any integration that acts on the outcome of a transaction. The receipt also carries gasUsed, logs, and the effective gas price, which are the fields needed for accounting and event processing. For bulk retrieval across a whole block, eth_getBlockReceipts: bulk receipts in one call returns the same receipt objects in a single request.

  • status 0x1 means success; status 0x0 means reverted.
  • A reverted transaction still consumes gas.
  • Receipt presence means inclusion, not success.
  • Receipts carry gasUsed, logs, and effective gas price.

Why a Null Cannot Be Distinguished From a Lagging Node Without a Second Query

A null receipt can mean the transaction is genuinely pending, or it can mean the node you queried has not yet seen the block that contains it. A single eth_getTransactionReceipt call cannot tell these apart. The Ethereum JSON-RPC specification does not require a node to return a receipt before it has processed the containing block, and provider-specific behavior varies by provider in how quickly a node follows the head.

The correct approach is to query both methods and classify the pair. If eth_getTransactionByHash returns a transaction object with a non-null blockNumber while the receipt is null, the node has seen the inclusion but has not yet produced the receipt, which is a lag condition rather than a pending transaction. If both are null, the hash is either unknown or the transaction has been dropped. This pair-based classification is the foundation of a correct poller.

  • A single receipt query cannot separate pending from lagging.
  • Query both eth_getTransactionByHash and eth_getTransactionReceipt.
  • Non-null blockNumber with null receipt indicates node lag.
  • Both null indicates unknown or dropped.

Timeout and Budget Design for a Safe Polling Loop

A polling loop needs three independent bounds to terminate correctly: a bounded poll interval, a maximum elapsed time, and a block-based deadline. The poll interval prevents request flooding and should be chosen to match the expected block time of the target chain. The maximum elapsed time caps the total wall-clock budget so a caller cannot hang indefinitely. The block-based deadline is the most reliable signal because it is derived from chain state rather than local time.

The block-based deadline uses the transaction nonce. Query eth_getTransactionCount with the latest block tag to see whether the nonce has been consumed. If the current head has advanced by more than a configurable number of blocks past the block in which the nonce was consumed, and no receipt exists, the transaction was likely dropped or replaced. This pattern is closely related to EVM nonce management with eth_getTransactionCount, which covers nonce gaps and replacement in detail.

  • Bounded poll interval matched to expected block time.
  • Maximum elapsed time as a wall-clock budget.
  • Block-based deadline derived from nonce consumption.
  • Nonce probe via eth_getTransactionCount('latest').

Exposing the Dropped-or-Replaced Case With a Nonce Probe

When eth_getTransactionByHash returns null and the receipt is also null, the transaction may have been dropped from the mempool or replaced by another transaction with the same nonce. A nonce probe distinguishes these cases. If eth_getTransactionCount('latest') shows the nonce has been consumed but the original hash has no receipt, a replacement transaction likely took its place. The Replacement transaction underpriced error is the common signal that a replacement attempt was rejected.

If the nonce has not been consumed and the head has advanced well beyond the expected inclusion window, the transaction was likely dropped due to fee conditions or mempool eviction. In both cases the poller should stop and report a terminal classification rather than continue polling. Continuing to poll a dropped transaction is the failure mode that produces infinite loops in production.

  • Nonce consumed with no receipt suggests replacement.
  • Nonce unconsumed with advanced head suggests drop.
  • Both cases are terminal for the poller.
  • Report the classification instead of polling forever.

Reorg-Affected Receipts Versus Unknown Receipts

A receipt can disappear after a chain reorganization. A transaction that was included in a block may be moved to a different block or returned to the mempool, and the receipt for the original block will no longer be available. A production poller should treat a receipt that vanishes as a signal to re-verify against a deeper block tag rather than as a permanent failure. The Ethereum JSON-RPC specification allows block tags such as finalized and safe, which provider support for varies by provider.

The practical approach is to record the block number from the receipt and, for high-value operations, re-query the receipt after a confirmation depth has passed. If the receipt is still present at the same block number, the inclusion is stable. If it has moved, the poller should update its record. This is the same reconciliation discipline used in Block-by-block EVM indexer reconciliation.

  • Receipts can vanish after a reorg.
  • Re-verify against a deeper block tag.
  • Record the receipt block number for stability checks.
  • Reconciliation logic belongs in indexers and high-value flows.

Runnable Node.js Receipt Poller With Per-Attempt Classification

The following Node.js poller queries both methods on each attempt, prints a classification, and terminates on receipt, drop, replacement, or budget exhaustion. It uses a bounded interval, a maximum elapsed time, and a block-based deadline. Replace the endpoint URL with your own RPC endpoint and run it against a test transaction.

The poller is intentionally dependency-free so it can be dropped into any Node.js project. It uses the global fetch API available in Node.js 18 and later. The classification function is the core logic and can be reused in a larger service.

const RPC_URL = 'https://your-endpoint.example';
const TX_HASH = '0x...';
const POLL_INTERVAL_MS = 4000;
const MAX_ELAPSED_MS = 180000;
const MAX_BLOCKS_PAST_NONCE = 20;

async function rpc(method, params) {
  const res = await fetch(RPC_URL, {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params })
  });
  const json = await res.json();
  if (json.error) throw new Error(json.error.message);
  return json.result;
}

function hexToInt(hex) {
  return hex === null || hex === undefined ? null : parseInt(hex, 16);
}

async function classify(txHash) {
  const [tx, receipt, headHex, nonceHex] = await Promise.all([
    rpc('eth_getTransactionByHash', [txHash]),
    rpc('eth_getTransactionReceipt', [txHash]),
    rpc('eth_blockNumber', []),
    rpc('eth_getTransactionCount', ['latest'])
  ]);
  const head = hexToInt(headHex);
  const nonce = hexToInt(nonceHex);
  if (receipt) {
    return { state: 'included', receipt, head, nonce };
  }
  if (tx && tx.blockNumber !== null) {
    return { state: 'lagging', tx, head, nonce };
  }
  if (tx && tx.blockNumber === null) {
    return { state: 'pending', tx, head, nonce };
  }
  return { state: 'unknown-or-dropped', head, nonce };
}

async function poll(txHash) {
  const start = Date.now();
  let lastNonceBlock = null;
  while (Date.now() - start < MAX_ELAPSED_MS) {
    const result = await classify(txHash);
    console.log(new Date().toISOString(), result.state, 'head=' + result.head, 'nonce=' + result.nonce);
    if (result.state === 'included') {
      return result.receipt;
    }
    if (result.state === 'unknown-or-dropped') {
      if (lastNonceBlock !== null && result.head - lastNonceBlock > MAX_BLOCKS_PAST_NONCE) {
        console.log('terminal: dropped or replaced');
        return null;
      }
      lastNonceBlock = result.head;
    }
    await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
  }
  console.log('terminal: budget exhausted');
  return null;
}

poll(TX_HASH).then((receipt) => {
  if (receipt) {
    console.log('status', receipt.status, 'block', receipt.blockNumber);
  }
});

Results Table for Measuring Against Your Own Endpoint

Because latency and inclusion timing vary by chain, provider, and network conditions, the only meaningful measurement is one taken against your own endpoint. Run the poller above against a known transaction and record the elapsed time from first poll to receipt, the number of attempts, and the classification sequence. Fill the table below with your own observations.

Do not compare these numbers across providers without controlling for block time and network load. The purpose of the table is to establish a baseline for your own integration so you can detect regressions when you change endpoints or polling parameters.

  • Attempt number: sequential count of poll iterations.
  • Elapsed ms: milliseconds since the first poll.
  • Classification: pending, lagging, included, or unknown-or-dropped.
  • Head block: eth_blockNumber at the time of the attempt.
  • Nonce: eth_getTransactionCount('latest') at the time of the attempt.

Tradeoffs Against Subscriptions and Bulk Receipt Retrieval

Polling is not the only option. WebSocket subscriptions can deliver new heads or logs with lower latency, but they require a persistent connection and provider support that varies by provider. For indexers that process whole blocks, eth_getBlockReceipts: bulk receipts in one call retrieves every receipt in a block in a single request, which is far more efficient than polling per transaction.

Polling remains the right choice when the client is short-lived, when the environment does not support WebSockets, or when the number of tracked transactions is small. The RPC endpoints guide (RPC Assistant) covers endpoint selection and transport options. For pricing and throughput planning, see RPC pricing.

  • Subscriptions offer lower latency but need persistent connections.
  • eth_getBlockReceipts is efficient for whole-block indexers.
  • Polling suits short-lived clients and small transaction sets.
  • Transport and endpoint choice affects reliability.

Troubleshooting Persistent Null Receipts

If a receipt remains null well beyond the expected inclusion window, check the transaction object first. A non-null blockNumber with a null receipt points to node lag, so retry against a different endpoint or wait for the node to catch up. A null transaction object with an unconsumed nonce points to a drop, and the transaction must be resubmitted with updated fees.

If the nonce has been consumed but the original hash has no receipt, a replacement transaction likely used the same nonce. Query the replacement hash or inspect the block at the nonce-consumption point. For a deeper treatment of replacement and nonce gaps, see Replacement transaction underpriced and EVM nonce management with eth_getTransactionCount.

  • Non-null blockNumber with null receipt: node lag, retry elsewhere.
  • Null transaction object with unconsumed nonce: dropped, resubmit.
  • Nonce consumed with no receipt: check for replacement.
  • Reorg: re-verify against a deeper block tag.

Next Steps for Production Receipt Handling

Move from a single poller to a small state machine that records each transaction's classification over time. Persist the receipt block number and status so downstream consumers can act on success or revert without re-querying. Add a confirmation depth before treating a receipt as final, and re-verify after a reorg window.

For broader integration patterns, start at the OnFinality Learn hub and review the API service documentation for endpoint behavior. The RPC endpoints guide (RPC Assistant) explains how to choose and configure endpoints for production workloads.

  • Persist classification and receipt block number.
  • Add a confirmation depth before finality.
  • Re-verify after a reorg window.
  • Choose endpoints with documented behavior for your chain.

Never Worry about Infrastructure Again

OnFinality takes away the heavy lifting of DevOps so you can build smarter and faster.

Get Started