Solana Transaction Stuck Pending: Why Status Returns Null

Three different states all look like pending, and the correct response is opposite in each. How to tell them apart before you resend.

BoltTx Team··8 min read
solanapendingconfirmationtransaction-landingtroubleshootingrpc

You have a signature. getSignatureStatuses returns null. Nothing is happening.

★"Pending" is not one state. It is three, and the right response to each is different — including one where resending is the worst thing you can do.★

The Three States Behind One Symptom

const { value } = await connection.getSignatureStatuses([sig]);
const st = value[0];

if (st === null) {
  // ★Either never arrived, or arrived and is waiting. Cannot tell yet.★
} else if (st.confirmationStatus === "processed") {
  // ★Executed. Waiting for votes. Do not resend.★
} else if (st.err) {
  // ★Landed and reverted. Resending identical bytes changes nothing.★
}
What you see What is true Correct action
null, blockhash valid ★Not yet included★ ★Keep resending★
null, blockhash expired ★Never will be★ ★Rebuild★
processed, no err Executed, awaiting votes ★Wait★
Status with err Landed and reverted ★Fix the instruction★

★The middle two are the ones people get wrong.★ Resending a transaction that already executed is wasted effort at best. Waiting on one whose blockhash expired is waiting forever.

The Only Question That Resolves It

Everything hinges on one comparison:

const height = await connection.getBlockHeight();

if (height > lastValidBlockHeight) {
  // ★Permanently invalid. Nothing will change this.★
  return "expired";
}
// ★Still inside the window — resending is correct.★

★A wall-clock timeout is the wrong instrument here.★ Slot production varies, so "it has been thirty seconds" tells you nothing about whether the blockhash is still valid. Block height is the only authority, and it is why lastValidBlockHeight must be kept alongside every blockhash you fetch.

If your transactions are expiring rather than reverting, a free BoltTx key is one line to test the submission path.

Resolving to a Terminal State

The loop that answers the question properly:

async function resolve(connection, sig, lastValidBlockHeight) {
  while (true) {
    const { value } = await connection.getSignatureStatuses([sig]);
    const st = value[0];

    if (st?.confirmationStatus === "confirmed" ||
        st?.confirmationStatus === "finalized") {
      return st.err ? { status: "reverted", err: st.err } : { status: "success" };
    }

    const height = await connection.getBlockHeight();
    if (height > lastValidBlockHeight) {
      // ★Last check: it may have landed between the two calls.★
      const final = await connection.getSignatureStatuses([sig]);
      if (final.value[0]) continue;
      return { status: "expired" };
    }

    await sleep(400);
  }
}

★The re-check after expiry matters more than it looks.★ Between reading the status and reading the height, a transaction can land. Returning expired without that final check produces a false negative — and if your code responds by rebuilding, you now have two transactions that can both execute.

Why processed Can Sit There

A transaction at processed has executed on some validator but has not been voted on. Usually it advances within a slot or two.

When it does not, the cause is almost always a fork. The block containing your transaction lost, so it never gets confirmed, and the transaction returns to a pending state as if it never ran.

★This is why processed is not a terminal state and must never be recorded as one.★ A bot that marks a trade complete at processed will occasionally have a completed trade that never happened.

The correct handling: treat processed as "still in flight," keep the resend loop running, and only commit to a result at confirmed or better.

Why It Never Arrived

If the status stays null through the whole window, the transaction never made it into a block. The causes are ordinary:

The fee was too low for the moment. Fee pressure is per-account and spikes precisely when you most want to land.

It was dropped in transit. A node under load can discard a transaction without telling you. ★This is why one submission is not a strategy★ — resending the same signed bytes is safe and is the only defence.

The account was contended. Transactions writing the same account serialise, so a popular pool has a queue you were in.

maxRetries was not zero. The RPC ran its own retry schedule underneath your loop, so two components were resending on different clocks and neither of you could reason about it.

The Diagnostic That Tells You Which Problem You Have

Over a batch of transactions rather than one:

metrics.record({ outcome, slotDistance: landSlot - submitSlot });

★Two rates, two different fixes:★

High expiry rate — transactions are not reaching block producers. Look at fees, retry behaviour, and routing.

High revert rate — they are landing fine and your instructions are wrong. Look at slippage, balances, and account existence.

Both low, but slot distance is wide — you are landing, just late. That is the routing half.

★Diagnosing from a single stuck transaction is guesswork; diagnosing from the distribution is not.★

What Landing Looks Like

Real transactions through our delivery nodes: median confirmation 336ms — under one slot.

★Against that number, a transaction still pending after many slots is unusual rather than normal.★ Knowing what typical looks like is what makes an anomaly visible.

Where BoltTx Fits

We handle submission. Confirmation logic stays in your code.

Submissions route through our own delivery nodes in four regions with stake-weighted routing and no public mempool exposure, so a transaction is not observable in transit before it lands.

You sign locally. We never hold funds, never sign, and never modify transaction contents — so resending identical bytes through us produces the same signature and cannot double-execute. The tip travels inside the transaction, paid on chain from your own wallet, and reverts with the transaction if it fails, because that is how Solana handles atomic transactions. You pay only on transactions that reach the chain.

Get a free API key. No monthly fee:

const connection = new Connection("https://la.bolttx.io/?api-key=YOUR_KEY");

FAQ

Why is my Solana transaction stuck pending? Either it has not been included yet, it executed and is awaiting votes, or it never arrived and the blockhash has expired. Compare block height against lastValidBlockHeight to tell which.

How do I know if a transaction will still land? Call getBlockHeight and compare with the lastValidBlockHeight returned alongside your blockhash. Past it, the transaction is permanently invalid regardless of how long you wait.

What does a null signature status mean? The transaction is not in a block the node knows about. That covers both "not yet" and "never" — only the block height comparison separates them.

Is it safe to resend a pending transaction? Yes, if the bytes are identical. One signature is included at most once, so continuous resubmission until expiry is the standard pattern and cannot double-execute.

Why does my transaction stay at processed? It executed on a validator but has not been voted on. If it stays there, the block likely lost a fork, and the transaction returns to pending as though it never ran.

Should I treat processed as confirmed? No. It can be rolled back, so recording a trade as complete at processed risks a completed record for something that never happened. Commit at confirmed or better.

How long should I wait before giving up? Until the blockhash expires, measured in block height rather than seconds. A wall-clock timeout is unreliable because slot production varies.

Why do I sometimes see expired when the transaction actually landed? Because it landed between your status check and your height check. Always re-check the status once more after detecting expiry, before concluding it never ran.

What happens if I rebuild a transaction that already landed? The rebuilt one has a new signature and can execute a second time. That is why you must resolve the original signature before rebuilding anything.

Does a higher priority fee fix pending transactions? It helps when the cause is contention, since fees are contested per account. It does nothing when the cause is a reverted instruction or an expired blockhash.

Why did my transaction disappear entirely? A node under load can drop a transaction without reporting it. Resending identical bytes is the defence, which is why a single submission attempt is not a strategy.

Should maxRetries be zero? Generally yes. Leaving it unset lets the RPC retry on its own schedule while your loop also retries, so two components resend on different clocks and failures become unreproducible.

How do I tell a revert from an expiry? A revert has a status object with err set, meaning it landed and a check rejected it. An expiry has no status at all past the block height limit, meaning it never executed.

Does a pending transaction cost anything? Not while pending. It costs a base fee only if it lands, including when it lands and reverts. A transaction that expires consumed no fee, only the opportunity.

Can I cancel a pending Solana transaction? No. There is no cancel mechanism, and the only thing that ends its life is the blockhash expiring. Durable-nonce transactions are the exception, since burning the nonce invalidates them.

What should I log for a stuck transaction? The signature, submission slot, lastValidBlockHeight, and every status response with its timestamp. Without those, the difference between expired and reverted cannot be reconstructed later.

Back to all posts