Priority fees on Solana: a practical guide

Fundamentals20 min read

A Solana priority fee is a per-compute-unit bid that decides where your transaction sits in the leader's scheduling queue. This covers how the fee is actually computed, how to price it from live network data instead of guessing, and the mistakes that make a fee do nothing.


the short version
  • A priority fee is a bid in micro-lamports per compute unit, not a flat fee per transaction.
  • Your total is units x price / 1,000,000. Request fewer units and the same bid costs less.
  • Contention is per account. Price against the accounts you write, not against a network-wide median.
  • Fees have a ceiling of usefulness. Past the clearing price for your accounts, more money buys nothing.

Priority fees are the first lever anyone reaches for, and they are also the lever most often pulled incorrectly. The mechanism is simple enough to describe in a paragraph and subtle enough that a great many production senders are quietly overpaying by an order of magnitude while still losing races.

This is what the fee actually is, how to price it from real data, and where it stops helping.

What a priority fee is#

Solana transactions carry a base fee, currently 5,000 lamports per signature, which is not negotiable. On top of that you can attach a priority fee: a bid that influences where your transaction sits in the leader’s scheduling queue.

You set it with two Compute Budget instructions.

basics.ts
1import { ComputeBudgetProgram, TransactionMessage, VersionedTransaction } from "@solana/web3.js";
2
3/**
4 * A priority fee is two instructions, and the order of the rest does not matter
5 * but these two should come first so the runtime sees the budget before it
6 * starts spending it.
7 *
8 * setComputeUnitLimit how many units you are asking for
9 * setComputeUnitPrice what you will pay per unit, in MICRO-lamports
10 *
11 * Total priority fee, in lamports:
12 * units x microLamports / 1_000_000
13 *
14 * The compute budget instructions themselves cost units too (150 each at time
15 * of writing), so account for them in your limit.
16 */
17const message = new TransactionMessage({
18 payerKey: payer.publicKey,
19 recentBlockhash: blockhash,
20 instructions: [
21 ComputeBudgetProgram.setComputeUnitLimit({ units: 120_000 }),
22 ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 25_000 }),
23 ...yourInstructions,
24 ],
25}).compileToV0Message();
26
27// 120_000 x 25_000 / 1_000_000 = 3_000 lamports = 0.000003 SOL
28const priorityFeeLamports = (120_000 * 25_000) / 1_000_000;

The scheduler orders transactions by fee per compute unit, which is why the price is denominated that way. It is a bid for scheduling priority, not a payment for guaranteed inclusion, and it is not refunded if you lose.

The arithmetic people get wrong#

Three errors account for most mispricing, and all three are arithmetic rather than strategy.

Micro-lamports, not lamports

setComputeUnitPrice takes micro-lamports. One lamport is a million of them. Passing 5000 thinking you have bid 5,000 lamports has actually bid 0.005 lamports per unit, which on a 200,000 unit transaction is a priority fee of one thousand lamports. Off by a factor of a million in the direction that loses.

Per unit, not per transaction

Your total scales with the units you request. Two transactions bidding the same price pay wildly different amounts if one asks for 1.4 million units and the other asks for 120,000.

Requested unitsBid (µlamports/CU)Priority fee
1,400,00010,00014,000 lamports
200,00010,0002,000 lamports
120,00010,0001,200 lamports

Which produces a genuinely useful consequence: measuring your compute units is a fee optimisation. Trimming a request from 1.4 million to a measured 120,000 cuts your priority fee by more than ninety percent at the same competitive bid. See compute units explained.

Both instructions, or neither works

Setting a price without a limit leaves you on the default limit, which is very likely not what you measured. Setting a limit without a price means you have no priority fee at all. They come as a pair.

Contention is per account#

This is the conceptual mistake, and it is more costly than the arithmetic ones.

Solana executes transactions in parallel when they touch disjoint accounts, and serialises them when they collide. Scheduling is therefore a per-account competition. When you are trying to write to one hot AMM pool, you are competing with the other transactions writing to that pool, not with the whole network.

A network-wide median fee tells you about a population you are not in. getRecentPrioritizationFees accepts a list of writable accounts precisely so you can ask the right question.

fees.ts
1import { Connection, PublicKey } from "@solana/web3.js";
2
3/**
4 * Price against the accounts you are actually competing for.
5 *
6 * Solana schedules on account write locks, so contention is per account. The
7 * network-wide median fee is close to useless when you are trying to write to
8 * one hot pool that fifty other bots also want. Pass the writable accounts and
9 * you get what recent blocks charged for transactions touching THOSE.
10 */
11export async function recentFees(rpc: Connection, writable: PublicKey[]) {
12 const samples = await rpc.getRecentPrioritizationFees({
13 lockedWritableAccounts: writable.slice(0, 128), // the API caps the list
14 });
15
16 // Samples come back per slot, most recent last. Zero-fee slots mean there
17 // was no contention in that slot, which is signal, not noise: dropping them
18 // biases you upward. Keep them, but weight the recent ones more.
19 const recent = samples.slice(-40);
20
21 return {
22 all: recent.map((s) => s.prioritizationFee),
23 contended: recent.map((s) => s.prioritizationFee).filter((f) => f > 0),
24 contentionRate: recent.filter((s) => s.prioritizationFee > 0).length / Math.max(1, recent.length),
25 };
26}
27
28export function percentile(values: number[], p: number): number {
29 if (values.length === 0) return 0;
30 const sorted = [...values].sort((a, b) => a - b);
31 const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil(sorted.length * p) - 1));
32 return sorted[index];
33}
Resist the urge to discard zero-fee samples. A slot where nothing paid a priority fee is telling you there was no contention in that slot, which is exactly the information you need to decide whether to bid at all. Filtering them out biases every estimate upward and you will pay for that bias on every quiet transaction.

Pricing from live data#

The same logic in Rust and Python, since fee pricing usually lives wherever your hot path lives.

send.py
1from solders.compute_budget import set_compute_unit_limit, set_compute_unit_price
2from solders.pubkey import Pubkey
3from solana.rpc.api import Client
4
5
6def quote_fee(rpc: Client, writable: list[Pubkey], pct: float = 0.75) -> int:
7 """Micro-lamports per compute unit, priced from recent per-account samples."""
8 resp = rpc.get_recent_prioritization_fees(writable)
9 fees = sorted(s.prioritization_fee for s in resp.value[-40:] if s.prioritization_fee > 0)
10
11 if not fees:
12 return 1_000
13
14 idx = min(len(fees) - 1, max(0, int(len(fees) * pct) - 1))
15 return max(1_000, min(2_000_000, fees[idx]))
16
17
18def budget_instructions(units: int, micro_lamports: int):
19 # Order matters only in that these should precede the instructions whose
20 # budget they govern.
21 return [
22 set_compute_unit_limit(units),
23 set_compute_unit_price(micro_lamports),
24 ]

Choosing a percentile#

Once you are pricing from real samples the remaining question is which percentile to target, and that is a business decision rather than a technical one. It is the price of a miss.

PercentileSensible whenExpect
0.50Missing is cheap and you can retryLowest cost, loses contested slots
0.75A sensible default for most sendersClears most of the time, moderate cost
0.90Missing costs materially more than the feeClears nearly always, you overpay when quiet
0.99Almost never the right answerYou are bidding against outliers, not the market

Work it out rather than picking by feel. If a miss costs you 0.05 SOL of expected value and the difference between the median and the ninetieth percentile is 0.0004 SOL, the higher percentile is obviously correct and it is not close. If a miss costs you nothing because you simply try again in a second, it obviously is not.

An adaptive fee controller#

A fixed percentile is still a fixed decision made in advance. Feeding outcomes back gives you something that tracks the market.

controller.ts
1import { Connection, PublicKey } from "@solana/web3.js";
2import { percentile, recentFees } from "./fees";
3
4/**
5 * An adaptive fee controller.
6 *
7 * A static fee is wrong nearly all the time: too high when the network is
8 * quiet, too low exactly when it is not. This tracks recent outcomes and moves
9 * the target percentile, which is a more stable thing to control than the raw
10 * lamport figure.
11 *
12 * The asymmetry is deliberate. Climb fast when you are losing, because the cost
13 * of missing is usually much larger than the cost of overpaying. Decay slowly,
14 * because a single win does not prove the network calmed down.
15 */
16export class FeeController {
17 private target = 0.6; // percentile of recent contended fees
18 private readonly floor = 1_000; // micro-lamports, below this is noise
19 private readonly ceiling: number;
20
21 constructor(
22 private rpc: Connection,
23 opts: { ceilingMicroLamports?: number } = {},
24 ) {
25 this.ceiling = opts.ceilingMicroLamports ?? 2_000_000;
26 }
27
28 async quote(writable: PublicKey[]): Promise<number> {
29 const { contended, contentionRate } = await recentFees(this.rpc, writable);
30
31 // Nothing contended recently: pay the floor and keep the change.
32 if (contended.length === 0 || contentionRate < 0.1) return this.floor;
33
34 const bid = percentile(contended, this.target);
35 return Math.min(this.ceiling, Math.max(this.floor, Math.ceil(bid)));
36 }
37
38 /** Feed every outcome back in. Without this it is not a controller. */
39 observe(landed: boolean) {
40 if (landed) {
41 this.target = Math.max(0.4, this.target - 0.01); // decay slowly
42 } else {
43 this.target = Math.min(0.95, this.target + 0.06); // climb fast
44 }
45 }
46
47 get currentPercentile() {
48 return this.target;
49 }
50}

Two design choices are worth calling out. First, it controls the percentile rather than the raw fee. The percentile is a stable quantity across market conditions; the lamport figure moves by orders of magnitude in an hour. Second, the response is deliberately asymmetric: fast up, slow down. Losing a race usually costs more than overpaying for one, and a single success is weak evidence that contention has ended.

A controller without a hard ceiling is a way to lose a lot of money during an anomaly. During a genuine spike, percentile-following logic will chase the spike. Set a ceiling from the economics of your trade, not from what the network happens to be charging.

Five expensive mistakes#

  1. A constant somebody set once. The number that worked six months ago is not the number now, and nobody has checked.
  2. Requesting 1.4M units by default. Multiplies your fee by more than ten against a measured limit, and makes you look expensive to schedule.
  3. Pricing off the network median. Wrong population. Price against your accounts.
  4. Raising the fee to fix a delivery problem. If your transaction is never read, the fee is irrelevant. See the diagnostic.
  5. No feedback loop. If you never correlate fee against outcome you are not tuning, you are guessing with extra steps.

Where fees stop working#

There is a ceiling, and recognising it saves a great deal of wasted money and effort.

A priority fee competes for ordering among transactions the leader has already read. It does nothing about whether the leader reads you at all. That earlier question is decided by stake-weighted QoS at the connection layer, described in stake-weighted QoS explained.

The symptom of hitting this ceiling is distinctive:

  • raising the fee improved things, up to a point;
  • past that point, further increases changed nothing measurable;
  • your loss rate is now flat regardless of what you bid.

That flat line is not a fee problem. It is the shape of losing at admission rather than at ordering, and no amount of money fixes it, because the transaction carrying that money was never read.

A blunt but effective test: bid something absurd for a small sample, well past any sane ceiling. If landing rate does not move, the fee was never your constraint. Stop tuning it and go and look at your delivery path.
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