Measuring your real landing rate
Landing rate is the share of submitted transactions that reach a finalized block, and almost nobody measures it correctly. This covers how to instrument a sender end to end, which RPC calls give a truthful answer, how to separate delivery failures from on-chain failures, and how to turn the result into a number you can act on.
- There are three outcomes, not two: landed and succeeded, landed and failed, never seen. Conflating the last two hides everything.
- getSignatureStatuses without searchTransactionHistory is the single most common cause of a wrong landing rate.
- An aggregate rate is not actionable. Slice by blockhash freshness, fee decile, transport and route.
- A delivery rate that is flat across every condition you control is the signature of losing at admission.
Almost everyone running a Solana sender has a rough sense of their landing rate and almost nobody has a number they would defend. The measurement has a few sharp edges, and getting any of them wrong produces a figure that is confidently misleading in a direction you will not notice.
This is how to measure it so the answer is worth acting on.
Define it before you measure it#
“Landing rate” is used for at least three different quantities, and people comparing notes are frequently comparing different things.
| Metric | Numerator | Tells you about |
|---|---|---|
| Delivery rate | Reached a block at all, success or failure | Your path and your transaction validity |
| Success rate | Reached a block and executed without error | Delivery plus your application logic |
| Fill rate | Achieved the business outcome | Everything, including whether you were first |
Delivery rate is the one that measures infrastructure. A transaction that lands and fails on slippage was delivered perfectly; the delivery did its job and your slippage setting did not. Mixing those together produces a number that moves for reasons you cannot separate.
Track all three. Diagnose with the first.
Three outcomes, not two#
The binary framing of landed versus not landed is where most measurements go wrong. There are three states:
- Landed and succeeded. In a block, no error.
- Landed and failed. In a block, with an error, and you paid. Slippage, compute budget, a program assertion. This is a successful delivery.
- Never seen. No record anywhere. This is a delivery failure and it is the only one that infrastructure fixes.
Collapse two and three together and you get a metric that drops when you tighten your slippage, which will send you off investigating a delivery problem you do not have. I have watched exactly that happen and it cost a team a fortnight.
Instrumenting the sender#
The measurement is only as good as what you recorded at submission time. Record it synchronously, before the send resolves.
1/**2 * Record the conditions of every submission, at the moment you submit.3 *4 * This is the whole discipline. A landing rate with no context is a number you5 * can watch and cannot act on. The same number split by blockhash freshness,6 * fee percentile and venue tells you which lever to pull.7 *8 * Record it synchronously, before the send resolves. If you record after, you9 * will silently drop the submissions that threw.10 */11export type Submission = {12 signature: string;13 sentAtMs: number;1415 // Conditions you control, and therefore can correlate against.16 blockhashSlotsRemaining: number;17 feeMicroLamports: number;18 requestedComputeUnits: number;19 transport: "http" | "quic";20 route: string; // venue, program, or strategy name21 writableAccounts: string[];2223 // Filled in later by reconciliation.24 outcome?: "landed_ok" | "landed_failed" | "never_seen";25 landedSlot?: number;26 onChainError?: unknown;27 resolvedAtMs?: number;28};2930export class SubmissionLog {31 private open = new Map<string, Submission>();32 private closed: Submission[] = [];3334 record(s: Submission) {35 this.open.set(s.signature, s);36 }3738 resolve(signature: string, patch: Partial<Submission>) {39 const s = this.open.get(signature);40 if (!s) return;41 Object.assign(s, patch, { resolvedAtMs: Date.now() });42 this.open.delete(signature);43 this.closed.push(s);44 }4546 /** Anything still open past the blockhash window can never land. */47 expire(olderThanMs = 90_000) {48 const cutoff = Date.now() - olderThanMs;49 for (const [sig, s] of this.open) {50 if (s.sentAtMs < cutoff) {51 this.resolve(sig, { outcome: "never_seen" });52 }53 }54 }5556 drain(): Submission[] {57 const out = this.closed;58 this.closed = [];59 return out;60 }6162 get pending() {63 return [...this.open.values()];64 }65}
Recording before the send resolves matters more than it looks. If you record on success, every submission that threw is silently absent from your denominator, and your landing rate is measuring only the transactions that got far enough to succeed or fail cleanly.
Reconciling against the chain#
1import { Connection } from "@solana/web3.js";2import type { SubmissionLog } from "./log";34/**5 * Ask the chain what happened, in batches, on a loop.6 *7 * Two details decide whether this is truthful:8 *9 * searchTransactionHistory without it you only see the recent status cache,10 * a few hundred slots deep, and everything older11 * comes back null. This single flag is the most12 * common cause of a landing rate that looks far13 * worse than reality.14 *15 * batching getSignatureStatuses takes up to 256 signatures.16 * One call per signature will rate limit you and17 * the gaps will look like failures.18 */19export async function reconcile(rpc: Connection, log: SubmissionLog) {20 const pending = log.pending;21 if (pending.length === 0) return;2223 for (let i = 0; i < pending.length; i += 256) {24 const batch = pending.slice(i, i + 256);25 const { value } = await rpc.getSignatureStatuses(26 batch.map((s) => s.signature),27 { searchTransactionHistory: true },28 );2930 batch.forEach((submission, j) => {31 const status = value[j];32 if (!status) return; // not seen yet, leave it open3334 if (status.err) {35 log.resolve(submission.signature, {36 outcome: "landed_failed",37 landedSlot: status.slot,38 onChainError: status.err,39 });40 } else if (status.confirmationStatus) {41 log.resolve(submission.signature, {42 outcome: "landed_ok",43 landedSlot: status.slot,44 });45 }46 });47 }4849 // Anything still open past the window is provably dead. Closing these is50 // what stops your "pending" bucket growing forever and quietly inflating51 // your apparent success rate.52 log.expire();53}
searchTransactionHistory: true. Without it the RPC only checks a recent status cache a few hundred slots deep. Anything older returns null, you record it as never seen, and your delivery rate is understated by however long your reconciliation lag is. This is the most common measurement bug in this entire area and it always errs pessimistic.Expiring old pending submissions matters too. Without it your pending bucket grows without bound, and because pending is usually excluded from the denominator, your apparent success rate drifts upward over time for no reason at all.
Four ways to measure it wrong#
- Omitting
searchTransactionHistory. Understates delivery. Covered above because it is worth covering twice. - Counting on-chain failures as delivery failures. Makes application bugs look like infrastructure problems, and sends you shopping for the wrong fix.
- Measuring only the transactions you managed to send. If a submission throws before you get a signature back, it never enters your data. Those are real losses.
- Aggregating across conditions. An overall figure of 82% is not a finding. It is 96% on fresh blockhashes and 40% on stale ones, and only the split tells you what to do.
Slicing that makes it actionable#
1import type { Submission } from "./log";23/**4 * Landing rate, sliced by the things you control.5 *6 * An aggregate number tells you whether you have a problem. These slices tell7 * you which one, and that is the difference between a metric and a diagnosis.8 */9export function analyse(submissions: Submission[]) {10 const bucket = <K extends string>(key: (s: Submission) => K) => {11 const rows = new Map<K, { total: number; ok: number; failed: number; missing: number }>();12 for (const s of submissions) {13 const k = key(s);14 const row = rows.get(k) ?? { total: 0, ok: 0, failed: 0, missing: 0 };15 row.total += 1;16 if (s.outcome === "landed_ok") row.ok += 1;17 else if (s.outcome === "landed_failed") row.failed += 1;18 else row.missing += 1;19 rows.set(k, row);20 }21 return [...rows.entries()].map(([k, r]) => ({22 key: k,23 total: r.total,24 // Delivery rate: did it reach a block at all, regardless of outcome.25 deliveryRate: (r.ok + r.failed) / r.total,26 // Success rate: did it reach a block AND do what you wanted.27 successRate: r.ok / r.total,28 neverSeenRate: r.missing / r.total,29 }));30 };3132 return {33 byBlockhashFreshness: bucket((s) =>34 s.blockhashSlotsRemaining > 120 ? "fresh"35 : s.blockhashSlotsRemaining > 60 ? "middling"36 : "stale",37 ),38 byFeeDecile: bucket((s) => `d${Math.min(9, Math.floor(Math.log10(Math.max(1, s.feeMicroLamports))))}`),39 byTransport: bucket((s) => s.transport),40 byRoute: bucket((s) => s.route),41 byHour: bucket((s) => String(new Date(s.sentAtMs).getUTCHours())),42 };43}4445/**46 * The reading:47 *48 * deliveryRate varies with blockhash freshness -> window management49 * deliveryRate varies with fee decile -> underpricing50 * deliveryRate flat across everything -> the path51 * successRate low but deliveryRate high -> not a delivery problem52 * at all, look at slippage53 * and compute54 */
The slices map directly onto causes:
| Pattern | Diagnosis | Fix |
|---|---|---|
| Delivery drops sharply on stale blockhashes | Window management | Cache and rebuild. Free |
| Delivery climbs with fee decile, then plateaus | You were underpriced; you no longer are | Price at the plateau, not above it |
| Delivery flat across every condition | Admission, not ordering | The path |
| Delivery high, success low | Not a delivery problem | Slippage or compute budget |
| Delivery varies by route only | That venue’s transactions are the issue | Compute or size for that route |
| Delivery varies by hour | Congestion sensitivity | Adaptive fees, and a better path |
The second row is the one that saves money. A fee decile chart that plateaus tells you exactly where more spending stops buying anything, and most senders are bidding well past that point without knowing it.
Attributing a change#
Solana conditions move constantly, so a before-and-after comparison across a change is nearly worthless. Landing rate moved because the network got busier, not because of your deploy, and you have no way to tell.
The only reliable method is to run both concurrently.
- Split your flow. Send a fixed share through each path, randomised per transaction rather than per period.
- Randomise on the transaction, not the clock. Alternating by hour confounds your comparison with time of day, which is one of the strongest signals in the data.
- Keep everything else identical. Same fee logic, same compute, same routes. One variable.
- Wait for enough volume. A hundred transactions cannot distinguish 85% from 90%. Work out the sample size you need before you start, or you will read noise as a result.
This applies to evaluating any delivery provider, including us. Anyone who cannot be tested this way is asking you to take their word for it.
What to put on a dashboard#
Six panels. More than that and nobody looks at it.
- Delivery rate over time, hourly, with the never-seen share broken out. This is the headline.
- Delivery rate by blockhash freshness bucket. Should be flat. If it slopes, you have free wins available.
- Delivery rate by fee decile. Find the plateau; bid at it.
- Success rate by route. Separates application problems from delivery ones.
- Time from send to first seen on chain. A latency distribution, not an average. Watch p50 and p99 separately; they move for different reasons.
- Pending count. A growing pending bucket means your reconciliation is broken, and a broken reconciler makes every other panel a lie.
Once this is running you can stop arguing about landing rate from anecdote. You have a number, you know which conditions produce it, and you can tell whether a change helped. That is worth considerably more than any individual optimisation in these posts, because it is what tells you which of them you actually need.
If the answer turns out to be delivery, the diagnostic path is in why your transactions are not landing.