Logo
New RPC users get 35% off their first monthView the offer
OnFinality Learn
Network & Protocol Guides12 min read

Monad Transaction Lifecycle: Asynchronous Execution, Receipt Status, and Ordering

Understand Monad's async execution: how transaction receipts, status, and ordering differ from classic EVM chains, with a polling script.

TL;DR

Monad's asynchronous execution model decouples transaction ordering and commitment from execution. Unlike classic EVM chains, a transaction can be included in a block and appear in receipts before its execution (and that of its dependencies) completes. This guide explains the mechanism, how to interpret receipt status and ordering, and provides a reproducible polling script to observe the lifecycle on a Monad endpoint.

Direct Answer: What Changes with Monad's Asynchronous Execution

On a classic EVM chain, transactions in a block execute sequentially and the block is only produced after execution completes. Monad inverts this: it orders and commits blocks first, then executes transactions asynchronously and in parallel, using optimistic speculation and dependency tracking. As a result, the moment a transaction is included in a block and the moment it actually executes are not the same. When you query eth_getTransactionReceipt, you may see a receipt with a status that reflects the eventual outcome, but the execution may still be pending for that transaction or its dependencies. This guide explains the mechanism, what receipt fields mean under deferred execution, and how to build robust integrator code that does not assume immediate sequential execution.

The authoritative source for this behavior is the Monad documentation on Transaction Lifecycle and Asynchronous Execution. As of this writing, Monad is in testnet/devnet phases; mainnet behavior may differ. Always verify against the live docs and the specific endpoint you target. This guide provides a reproducible method to observe the lifecycle on your own endpoint.

Monad's Execution Model: Order First, Execute Later

Monad uses a pipelined architecture where block production is separated from execution. The consensus layer agrees on the order of transactions and blocks (the canonical order) before execution happens. This is often described as 'agree first, execute later' (see Monad's forum post). Execution is then performed asynchronously, with multiple blocks being executed in parallel where dependencies allow.

To make this safe, Monad uses optimistic execution with speculation. When a block is proposed, the execution layer speculatively executes transactions based on the current state, even if some dependencies (earlier transactions in the same block or from previous blocks) have not yet finished executing. Dependency tracking ensures that a transaction that reads state written by another transaction waits for that write to be available. If a speculative execution turns out to be incorrect (e.g., because a dependency's actual result differs from the speculated state), the execution is rolled back and re-executed. This reconciliation is internal and should not affect the final committed result, which follows the canonical order.

For the integrator, the key takeaway is that the canonical order of transactions is fixed at commitment time, but the execution of a transaction may lag behind. This means that when you see a transaction in a block (via eth_getBlockByNumber or similar), its execution may not have completed yet. The receipt, when available, reflects the outcome of that execution, but its availability does not guarantee that all prior transactions have executed.

Transaction Submission and Inclusion vs. Execution

Submitting a transaction on Monad uses the standard eth_sendRawTransaction method (or your library's equivalent). The transaction is broadcast to the network and eventually included in a block. Inclusion means the transaction is part of the canonical order and has a block number and transaction index. However, inclusion does not mean execution has occurred.

To check inclusion, you can use eth_getTransactionByHash. This returns the transaction details, including blockNumber and blockHash, once the transaction is included. But the transaction's status (success/failure) is only known after execution. For that, you need eth_getTransactionReceipt.

The receipt contains a status field (0x1 for success, 0x0 for failure) and logs (for events). On Monad, the receipt may be available before the transaction has actually executed, because the receipt is generated as part of the block's metadata. However, the status and logs reflect the eventual outcome after execution and reconciliation. In practice, you might see a receipt with a blockNumber but the transaction's execution is still pending. This is a departure from classic chains where receipt availability implies execution completion.

Receipt Status Semantics Under Deferred Execution

When you call eth_getTransactionReceipt on Monad, the returned object includes the standard fields: transactionHash, transactionIndex, blockHash, blockNumber, from, to, cumulativeGasUsed, gasUsed, contractAddress, logs, logsBloom, status, and effectiveGasPrice. The status field is particularly important: it indicates whether the transaction will succeed or fail once executed. But because execution is asynchronous, a receipt with status: 0x1 does not mean the state changes have been applied yet—only that the execution engine has determined the outcome.

For transactions that depend on other transactions (e.g., a contract call that reads state written by a previous transaction), the receipt might not be available until those dependencies are executed. In practice, you may see a receipt appear later than the block inclusion, or you might see a receipt with a status that later changes if a speculative execution is rolled back (though this should be rare and is not part of the public API guarantee).

Monad's documentation states that the execution is deterministic and that the final state matches the canonical order. Therefore, you can trust the status and logs in the receipt as the final outcome, even if execution is still in progress. However, you should not assume that the transaction's effects are visible in state queries (e.g., eth_getBalance or eth_call) immediately after the receipt is available. There may be a lag between receipt availability and state finalization.

Ordering Guarantees and Nonce Management

Monad preserves the canonical order of transactions as determined by the consensus layer. This means that for a given account, transactions are ordered by nonce, and the final state reflects that order. However, because execution is asynchronous, you cannot assume that a transaction with a lower nonce has executed before a higher-nonce transaction is included. The ordering is only guaranteed at the state level, not at the execution timeline.

For nonce management, this has implications. If you send multiple transactions from the same account, you must still increment the nonce correctly, as on any EVM chain. But you should not rely on the execution of a previous transaction to be complete before sending the next. Instead, you should track the nonce and use eth_getTransactionCount with the appropriate block parameter (e.g., 'pending' or 'latest') to determine the next available nonce. For a deeper dive, see our guide on EVM nonce management under concurrency.

When building a wait-for-receipt loop, you should poll eth_getTransactionReceipt until it returns a non-null result. However, because execution may lag, you might also want to wait until the transaction's effects are visible in state, if your application depends on that. For example, if you send a transfer and then want to check the recipient's balance, you should poll eth_getBalance until it reflects the expected value, rather than assuming it is updated immediately after the receipt is available.

Practical Example: Observing the Lifecycle with a Node.js Script

The following script sends a simple transfer transaction to a Monad endpoint (you must supply the endpoint URL and a private key with funds). It then polls eth_getTransactionReceipt every second for up to 30 seconds, printing the block number, status, and number of logs at each poll. This allows you to observe when the receipt becomes available relative to inclusion. Run it against a Monad testnet/devnet endpoint; do not assume mainnet availability.

Limitations: This script is for educational purposes. The exact timing and behavior may vary by endpoint and network phase. Always verify against the live Monad docs and your endpoint's behavior.

const { Web3 } = require('web3');

// Configuration - replace with your endpoint and private key
const RPC_URL = 'https://your-monad-endpoint.example.com';
const PRIVATE_KEY = '0x...';
const TO_ADDRESS = '0x...';

const web3 = new Web3(RPC_URL);

async function main() {
  const account = web3.eth.accounts.privateKeyToAccount(PRIVATE_KEY);
  web3.eth.accounts.wallet.add(account);

  // Get nonce
  const nonce = await web3.eth.getTransactionCount(account.address, 'pending');

  // Build transaction
  const tx = {
    from: account.address,
    to: TO_ADDRESS,
    value: web3.utils.toWei('0.001', 'ether'),
    gas: 21000,
    gasPrice: await web3.eth.getGasPrice(),
    nonce: nonce
  };

  // Sign and send
  const signedTx = await web3.eth.accounts.signTransaction(tx, PRIVATE_KEY);
  const txHash = await web3.eth.sendSignedTransaction(signedTx.rawTransaction);
  console.log('Transaction hash:', txHash);

  // Poll for receipt
  const timeout = 30000; // 30 seconds
  const start = Date.now();
  let receipt = null;
  while (Date.now() - start < timeout) {
    receipt = await web3.eth.getTransactionReceipt(txHash);
    if (receipt) {
      console.log(`Poll at ${Date.now() - start}ms: blockNumber=${receipt.blockNumber}, status=${receipt.status}, logs=${receipt.logs.length}`);
      // Optionally break when status is defined
      if (receipt.status !== undefined) break;
    } else {
      console.log(`Poll at ${Date.now() - start}ms: receipt not yet available`);
    }
    await new Promise(resolve => setTimeout(resolve, 1000));
  }

  if (!receipt) {
    console.log('Timeout: receipt not available within 30s');
  }
}

main().catch(console.error);

Results Table: Fill in Your Observations

Run the script against your Monad endpoint and record the timings. This table will help you understand the relationship between inclusion and execution on your specific network.

  • Time to receipt available: The time from sending the transaction to the first non-null receipt response.
  • Time to status defined: The time when the receipt's status field is no longer undefined (if applicable).
  • Block number at first receipt: The block number when the receipt first appears.
  • Number of polls until status: How many polls it took to get a definitive status.
| Metric | Value (seconds) |
|--------|-----------------|
| Time to first receipt | |
| Time to status defined | |
| Block number at first receipt | |
| Polls until status | |

Troubleshooting: Common Pitfalls and Fixes

When integrating with Monad's async execution, you may encounter issues that stem from assuming immediate execution. Here are common pitfalls and how to address them.

  • Receipt not available after inclusion: If you see the transaction in a block but eth_getTransactionReceipt returns null, it means execution has not completed. Wait and poll again. Do not assume the transaction failed.
  • Status field is null: Some RPC implementations may return a receipt with status: null if execution is still pending. Treat this as 'unknown', not as failure.
  • State not updated after receipt: If you query a balance or call a contract immediately after receiving a receipt, the state may not reflect the transaction's effects yet. Poll the state until it matches expectations.
  • Nonce too low or too high: Because execution is async, you might send a transaction with a nonce that is too high if you rely on the latest executed nonce. Use eth_getTransactionCount with 'pending' to get the next expected nonce.
  • Event logs missing: If you index logs from a transaction that has not executed yet, you may miss them. Ensure you poll for logs after the receipt is available and the execution is complete.
  • Reverted transactions: If a transaction reverts, the receipt will have status: 0x0. This is final. However, the revert might happen after a delay, so be prepared for a late failure.

Limitations and Tradeoffs of Asynchronous Execution

Monad's asynchronous execution offers higher throughput by pipelining, but it introduces complexity for developers. The main tradeoff is that you cannot rely on synchronous execution semantics. This affects debugging, event indexing, and any logic that assumes immediate state changes.

Additionally, the exact behavior of receipt availability and status semantics may evolve as Monad moves toward mainnet. The documentation is the primary source, but you should test against your target endpoint. For example, some endpoints might return a receipt only after execution is complete, while others might return it earlier. This is not standardized yet.

For historical data, note that Monad's archive nodes may have different behavior. See our guide on Querying Monad historical state over RPC for more.

Next Steps and Further Reading

To build reliable applications on Monad, you need to understand its unique transaction lifecycle. Start by reading the official Monad Transaction Lifecycle and Asynchronous Execution docs. Then, experiment with the script above on a testnet.

For more Monad-specific guidance, explore our other resources:

Never Worry about Infrastructure Again

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

Get Started