Priority fees on Solana: a practical guide
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.
- 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.
1import { ComputeBudgetProgram, TransactionMessage, VersionedTransaction } from "@solana/web3.js";23/**4 * A priority fee is two instructions, and the order of the rest does not matter5 * but these two should come first so the runtime sees the budget before it6 * starts spending it.7 *8 * setComputeUnitLimit how many units you are asking for9 * setComputeUnitPrice what you will pay per unit, in MICRO-lamports10 *11 * Total priority fee, in lamports:12 * units x microLamports / 1_000_00013 *14 * The compute budget instructions themselves cost units too (150 each at time15 * 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();2627// 120_000 x 25_000 / 1_000_000 = 3_000 lamports = 0.000003 SOL28const 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 units | Bid (µlamports/CU) | Priority fee |
|---|---|---|
| 1,400,000 | 10,000 | 14,000 lamports |
| 200,000 | 10,000 | 2,000 lamports |
| 120,000 | 10,000 | 1,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.
1import { Connection, PublicKey } from "@solana/web3.js";23/**4 * Price against the accounts you are actually competing for.5 *6 * Solana schedules on account write locks, so contention is per account. The7 * network-wide median fee is close to useless when you are trying to write to8 * one hot pool that fifty other bots also want. Pass the writable accounts and9 * 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 list14 });1516 // Samples come back per slot, most recent last. Zero-fee slots mean there17 // was no contention in that slot, which is signal, not noise: dropping them18 // biases you upward. Keep them, but weight the recent ones more.19 const recent = samples.slice(-40);2021 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}2728export 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}
Pricing from live data#
The same logic in Rust and Python, since fee pricing usually lives wherever your hot path lives.
1from solders.compute_budget import set_compute_unit_limit, set_compute_unit_price2from solders.pubkey import Pubkey3from solana.rpc.api import Client456def 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)1011 if not fees:12 return 1_0001314 idx = min(len(fees) - 1, max(0, int(len(fees) * pct) - 1))15 return max(1_000, min(2_000_000, fees[idx]))161718def budget_instructions(units: int, micro_lamports: int):19 # Order matters only in that these should precede the instructions whose20 # 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.
| Percentile | Sensible when | Expect |
|---|---|---|
0.50 | Missing is cheap and you can retry | Lowest cost, loses contested slots |
0.75 | A sensible default for most senders | Clears most of the time, moderate cost |
0.90 | Missing costs materially more than the fee | Clears nearly always, you overpay when quiet |
0.99 | Almost never the right answer | You 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.
1import { Connection, PublicKey } from "@solana/web3.js";2import { percentile, recentFees } from "./fees";34/**5 * An adaptive fee controller.6 *7 * A static fee is wrong nearly all the time: too high when the network is8 * quiet, too low exactly when it is not. This tracks recent outcomes and moves9 * the target percentile, which is a more stable thing to control than the raw10 * lamport figure.11 *12 * The asymmetry is deliberate. Climb fast when you are losing, because the cost13 * 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 fees18 private readonly floor = 1_000; // micro-lamports, below this is noise19 private readonly ceiling: number;2021 constructor(22 private rpc: Connection,23 opts: { ceilingMicroLamports?: number } = {},24 ) {25 this.ceiling = opts.ceilingMicroLamports ?? 2_000_000;26 }2728 async quote(writable: PublicKey[]): Promise<number> {29 const { contended, contentionRate } = await recentFees(this.rpc, writable);3031 // Nothing contended recently: pay the floor and keep the change.32 if (contended.length === 0 || contentionRate < 0.1) return this.floor;3334 const bid = percentile(contended, this.target);35 return Math.min(this.ceiling, Math.max(this.floor, Math.ceil(bid)));36 }3738 /** 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 slowly42 } else {43 this.target = Math.min(0.95, this.target + 0.06); // climb fast44 }45 }4647 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.
Five expensive mistakes#
- A constant somebody set once. The number that worked six months ago is not the number now, and nobody has checked.
- Requesting 1.4M units by default. Multiplies your fee by more than ten against a measured limit, and makes you look expensive to schedule.
- Pricing off the network median. Wrong population. Price against your accounts.
- Raising the fee to fix a delivery problem. If your transaction is never read, the fee is irrelevant. See the diagnostic.
- 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.