Measuring your real landing rate

Operations20 min read

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.


the short version
  • 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.

MetricNumeratorTells you about
Delivery rateReached a block at all, success or failureYour path and your transaction validity
Success rateReached a block and executed without errorDelivery plus your application logic
Fill rateAchieved the business outcomeEverything, 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:

  1. Landed and succeeded. In a block, no error.
  2. Landed and failed. In a block, with an error, and you paid. Slippage, compute budget, a program assertion. This is a successful delivery.
  3. 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.

log.ts
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 you
5 * 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, you
9 * will silently drop the submissions that threw.
10 */
11export type Submission = {
12 signature: string;
13 sentAtMs: number;
14
15 // 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 name
21 writableAccounts: string[];
22
23 // Filled in later by reconciliation.
24 outcome?: "landed_ok" | "landed_failed" | "never_seen";
25 landedSlot?: number;
26 onChainError?: unknown;
27 resolvedAtMs?: number;
28};
29
30export class SubmissionLog {
31 private open = new Map<string, Submission>();
32 private closed: Submission[] = [];
33
34 record(s: Submission) {
35 this.open.set(s.signature, s);
36 }
37
38 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 }
45
46 /** 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 }
55
56 drain(): Submission[] {
57 const out = this.closed;
58 this.closed = [];
59 return out;
60 }
61
62 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#

reconcile.ts
1import { Connection } from "@solana/web3.js";
2import type { SubmissionLog } from "./log";
3
4/**
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 older
11 * comes back null. This single flag is the most
12 * common cause of a landing rate that looks far
13 * worse than reality.
14 *
15 * batching getSignatureStatuses takes up to 256 signatures.
16 * One call per signature will rate limit you and
17 * 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;
22
23 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 );
29
30 batch.forEach((submission, j) => {
31 const status = value[j];
32 if (!status) return; // not seen yet, leave it open
33
34 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 }
48
49 // Anything still open past the window is provably dead. Closing these is
50 // what stops your "pending" bucket growing forever and quietly inflating
51 // your apparent success rate.
52 log.expire();
53}
If you take one line from this post, take 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#

  1. Omitting searchTransactionHistory. Understates delivery. Covered above because it is worth covering twice.
  2. Counting on-chain failures as delivery failures. Makes application bugs look like infrastructure problems, and sends you shopping for the wrong fix.
  3. 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.
  4. 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#

analyse.ts
1import type { Submission } from "./log";
2
3/**
4 * Landing rate, sliced by the things you control.
5 *
6 * An aggregate number tells you whether you have a problem. These slices tell
7 * 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 };
31
32 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}
44
45/**
46 * The reading:
47 *
48 * deliveryRate varies with blockhash freshness -> window management
49 * deliveryRate varies with fee decile -> underpricing
50 * deliveryRate flat across everything -> the path
51 * successRate low but deliveryRate high -> not a delivery problem
52 * at all, look at slippage
53 * and compute
54 */

The slices map directly onto causes:

PatternDiagnosisFix
Delivery drops sharply on stale blockhashesWindow managementCache and rebuild. Free
Delivery climbs with fee decile, then plateausYou were underpriced; you no longer arePrice at the plateau, not above it
Delivery flat across every conditionAdmission, not orderingThe path
Delivery high, success lowNot a delivery problemSlippage or compute budget
Delivery varies by route onlyThat venue’s transactions are the issueCompute or size for that route
Delivery varies by hourCongestion sensitivityAdaptive 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.

  1. Delivery rate over time, hourly, with the never-seen share broken out. This is the headline.
  2. Delivery rate by blockhash freshness bucket. Should be flat. If it slopes, you have free wins available.
  3. Delivery rate by fee decile. Find the plateau; bid at it.
  4. Success rate by route. Separates application problems from delivery ones.
  5. Time from send to first seen on chain. A latency distribution, not an average. Watch p50 and p99 separately; they move for different reasons.
  6. Pending count. A growing pending bucket means your reconciliation is broken, and a broken reconciler makes every other panel a lie.
Alert on the shape, not the level. Landing rate legitimately drops during congestion and that is not an incident. What is worth waking someone for is delivery rate falling while fee percentile and blockhash freshness stayed constant, because that combination means something changed on the path rather than in the market.

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.

Stop guessing at delivery.

swqos.com forwards your signed transactions to the leader over staked connections held open and kept warm. One prepaid balance, a flat price per send.

Get an API key
keep reading