Logo
New RPC users get 35% off their first monthView the offer
OnFinality Learn
RPC Troubleshooting13 min read

Solana Priority Fee Estimation: setComputeUnitPrice in Production

Derive a per-transaction compute unit price from getRecentPrioritizationFees, attach ComputeBudget instructions, and verify the choice by landing rate.

TL;DR

Solana priority fees are not a single number you copy from a tracker; they are a per-transaction decision made from RPC data. The fee formula is ceil(compute unit limit x compute unit price / 1,000,000) lamports, so both the limit and the price are inputs. getRecentPrioritizationFees returns an array of { slot, prioritizationFee } samples from recent blocks, and passing the transaction's writable accounts scopes that sample to slots where those accounts were written. Read the result as a distribution, take a high percentile rather than the mean, and convert a lamport budget into a compute unit price. Then close the loop by recording slot-submitted and slot-confirmed to measure landing rate, adjusting the percentile with hysteresis. This article provides a runnable Node.js estimator, a results table to fill against your own endpoint, and a troubleshooting playbook for empty samples, unsupported methods, and overpayment.

The Solana fee formula and why the compute unit limit is an input

Solana transaction fees are documented as a fixed base fee plus a prioritization fee. The base fee is charged per signature, while the prioritization fee is computed as ceil(compute unit limit x compute unit price / 1,000,000) lamports. The authoritative reference is the Solana documentation on the fee structure at https://solana.com/docs/core/fees. Because the compute unit limit appears in the numerator, a limit set far above actual usage inflates the fee you pay for the same compute unit price.

This is why the order of operations matters. Set a realistic compute unit limit first, usually from a simulation, and only then choose a compute unit price. If you skip the limit and leave the default, you may pay a prioritization fee on headroom your transaction never consumes. The ComputeBudget program (ComputeBudget111111111111111111111111111111) exposes setComputeUnitLimit and setComputeUnitPrice instructions; the Solana JSON-RPC documentation for these instructions and for getFeeForMessage is authoritative for how the per-message fee is queried.

A practical consequence is that two transactions with the same compute unit price can pay different prioritization fees. The one with the tighter limit pays less. Treat the limit as a correctness and cost parameter, not a formality.

The authoritative reference for both halves of that formula is the Solana documentation's fee structure page, and the sample contract used below is specified in the getRecentPrioritizationFees method reference. Treat those two pages as the source of truth and this article as the operational procedure built on top of them.

  • Base fee: fixed per signature, independent of compute.
  • Prioritization fee: ceil(compute unit limit x compute unit price / 1,000,000) lamports.
  • Compute unit limit: set from simulation before selecting a price.
  • Compute unit price: the bid you attach via the ComputeBudget program.

getRecentPrioritizationFees as a sample of fees paid, not a suggested bid

The getRecentPrioritizationFees method returns an array of objects shaped { slot, prioritizationFee } drawn from a trailing window of recent blocks. The authoritative contract is the Solana JSON-RPC documentation at https://solana.com/docs/rpc/http/getrecentprioritizationfees. Critically, each entry reports the prioritization fee paid by transactions in that slot, not a recommended price. The method is descriptive, not prescriptive.

Because the response is a sample, the correct mental model is a distribution. The mean is a poor summary because a single busy slot can pull it upward, and the samples are unweighted across slots. Sorting the array and taking a high percentile gives a value that a meaningful fraction of recent slots met or exceeded, which is a more stable basis for a bid.

The optional addresses argument changes the meaning of the sample. When you pass the transaction's writable accounts, the method scopes the sample to slots where at least one of those accounts was written. That is the difference between a distribution relevant to your contention and a pool of unrelated fees.

  • Response shape: array of { slot, prioritizationFee }.
  • Semantics: fees paid in those slots, not a suggested price.
  • Aggregation: sort and take a percentile, not the mean.
  • Scoping: pass writable accounts to make the sample relevant.

Scoping the sample with the addresses argument

The addresses argument is the most underused part of the method. Without it, the sample pools fees from transactions touching unrelated accounts. On a quiet chain, that pool can legitimately have a median of zero, which is the visible symptom behind the recurring Stack Exchange threads asking why getRecentPrioritizationFees always returns 0. The method is not broken; the sample is simply not scoped to anything you care about.

Pass the writable accounts your transaction will write. The Solana fee documentation defines a writable account for this filter, and the method documentation confirms the scoping behavior. Read-only accounts do not qualify, so a transaction that only reads a popular program will not narrow the sample through that program's address.

Scoping also makes the estimate actionable. A fee distribution for the accounts you contend on tells you what other writers to those accounts recently paid. That is the population your transaction competes with for inclusion.

  • Pass writable accounts, not read-only accounts.
  • Unscoped samples can legitimately be zero on a quiet chain.
  • Scoped samples reflect contention on the accounts you write.
  • If your transaction writes nothing, scoping has limited effect.

Reading a distribution: percentiles, windows, and unweighted slots

Once you have a scoped sample, sort the prioritizationFee values ascending and index a percentile. A high percentile, such as the 75th or 90th, is a common starting point because it reflects a level that a large fraction of recent slots met. The exact percentile is a policy choice you tune against landing rate, not a universal constant.

The samples are unweighted across slots, which is a feature for percentile reads. A single busy slot contributes one observation, so it cannot dominate the percentile the way it can dominate a mean. This makes percentile selection more robust to outliers.

The trailing window is a limitation. If the window covers an unrepresentative period, such as a lull before a known event, the percentile will understate the fee needed during the event. Treat the window as a recent-history estimate, not a forecast.

  • Sort ascending, then index the percentile.
  • Unweighted slots limit the influence of any single busy slot.
  • The trailing window may not represent an upcoming congestion spike.
  • Percentile choice is a tunable policy, not a fixed constant.

A runnable Node.js estimator using @solana/web3.js

The estimator below builds a transaction, simulates it to obtain the compute unit limit, calls getRecentPrioritizationFees with the writable addresses, takes a configurable percentile, converts a lamport budget into a compute unit price, and attaches the ComputeBudget instructions. It uses @solana/web3.js and assumes a connection to a Solana RPC endpoint. Replace the placeholder accounts and endpoint with your own.

The conversion from a lamport budget to a compute unit price inverts the fee formula: price = ceil(budgetLamports x 1,000,000 / computeUnitLimit). This keeps the prioritization fee at or below your budget for the chosen limit. If you prefer to derive the budget from the percentile sample, use the sampled prioritizationFee directly as the budget.

Run this against your endpoint and record the outputs. The next section turns those outputs into a landing-rate measurement.

import {
  Connection,
  PublicKey,
  TransactionMessage,
  VersionedTransaction,
  ComputeBudgetProgram,
} from '@solana/web3.js';

const RPC_URL = process.env.SOLANA_RPC_URL || 'https://api.mainnet-beta.solana.com';
const connection = new Connection(RPC_URL, 'confirmed');

// Replace with the writable accounts your transaction contends on.
const writableAccounts = [
  new PublicKey('11111111111111111111111111111111'),
];

function percentile(sortedAsc, p) {
  if (sortedAsc.length === 0) return 0;
  const idx = Math.min(
    sortedAsc.length - 1,
    Math.max(0, Math.ceil((p / 100) * sortedAsc.length) - 1)
  );
  return sortedAsc[idx];
}

async function estimateComputeUnitPrice({ percentileTarget = 75, budgetLamports = null }) {
  const samples = await connection.getRecentPrioritizationFees({
    lockedWritableAccounts: writableAccounts,
  });

  const fees = samples
    .map((s) => s.prioritizationFee)
    .filter((f) => Number.isFinite(f))
    .sort((a, b) => a - b);

  const sampledFee = percentile(fees, percentileTarget);
  const budget = budgetLamports ?? sampledFee;

  return { samples, fees, sampledFee, budget };
}

async function buildWithComputeBudget({ instructions, payer, percentileTarget = 75 }) {
  const { budget } = await estimateComputeUnitPrice({ percentileTarget });

  const { blockhash } = await connection.getLatestBlockhash('confirmed');
  const message = new TransactionMessage({
    payerKey: payer,
    recentBlockhash: blockhash,
    instructions,
  }).compileToV0Message();

  const probe = new VersionedTransaction(message);
  const sim = await connection.simulateTransaction(probe, { replaceRecentBlockhash: true });
  const unitsConsumed = sim.value.unitsConsumed ?? 200_000;
  const computeUnitLimit = Math.ceil(unitsConsumed * 1.2);

  const computeUnitPrice = Math.ceil((budget * 1_000_000) / computeUnitLimit);

  const withBudget = new TransactionMessage({
    payerKey: payer,
    recentBlockhash: blockhash,
    instructions: [
      ComputeBudgetProgram.setComputeUnitLimit({ units: computeUnitLimit }),
      ComputeBudgetProgram.setComputeUnitPrice({ microLamports: computeUnitPrice }),
      ...instructions,
    ],
  }).compileToV0Message();

  return { tx: new VersionedTransaction(withBudget), computeUnitLimit, computeUnitPrice, budget };
}

export { estimateComputeUnitPrice, buildWithComputeBudget, percentile };

Closing the loop: measuring landing rate and adjusting with hysteresis

Choosing a price is only half the task. The other half is verifying that the price lands transactions at an acceptable rate. Record slot-submitted and slot-confirmed for each transaction, then compute blocks-to-confirmation as the difference. Aggregate over a window to get a landing rate and a median blocks-to-confirmation.

Adjust the percentile against a target. If landing rate is below target, raise the percentile; if it is above target and you are overpaying, lower it. Add hysteresis so the price does not oscillate around the target: require the landing rate to deviate by a margin before changing the percentile, and cap the change per adjustment.

This closed loop turns a static estimate into a control system. The percentile becomes the control variable, landing rate the measured output, and hysteresis the damping. Without it, you are guessing at a number and never learning whether it was right.

  • Record slot-submitted and slot-confirmed per transaction.
  • Compute blocks-to-confirmation as the slot difference.
  • Raise the percentile when landing rate is below target.
  • Apply hysteresis to avoid oscillation around the target.

Results table to fill against your own endpoint

The table below is a template. Fill it in against your own RPC endpoint and workload; do not treat any row as a benchmark. The goal is to see how percentile choice maps to compute unit price, fee paid, and blocks to landing for your specific accounts and traffic.

Run each percentile over a fixed number of transactions, record the median and spread, and compare. The endpoint you use matters: a dedicated node may return a different sample than a shared public endpoint. If you need consistent sampling, review RPC pricing and consider a dedicated endpoint via the API service.

  • Percentile: the target percentile used for the bid.
  • Compute unit price: the microLamports value attached.
  • Fee paid: the prioritization fee in lamports, from the confirmed transaction.
  • Blocks to landing: slot-confirmed minus slot-submitted.
  • Landing rate: fraction of submitted transactions confirmed within your window.

Troubleshooting: empty samples, unsupported methods, and overpayment

An empty or all-zero sample array is the most common complaint. If the array is empty, the trailing window may have no qualifying slots for your scoped accounts, or your endpoint may not support the addresses argument. If the array is all zeros, the sample is likely unscoped or the chain is quiet for those accounts. Re-check that you passed writable accounts and that your endpoint honors the parameter.

If the endpoint does not expose the method, the response is a JSON-RPC error object. The JSON-RPC 2.0 Specification at https://jsonrpc.org/specification defines the error envelope: a code, a message, and optional data. Decode that object rather than assuming a network failure. A method-not-found code indicates the endpoint does not implement getRecentPrioritizationFees.

Overpayment usually traces to a compute unit limit set far above actual usage. Because the limit is in the numerator of the fee formula, an inflated limit inflates the fee at the same price. Re-simulate and tighten the limit. Also remember that preflight failure means the transaction never executes, so no prioritization fee is paid; a fee is only paid on execution. For preflight and retry semantics, see Solana sendTransaction preflight and safe retries and Solana RPC timeouts and retry strategy.

  • Empty array: no qualifying slots or unsupported addresses argument.
  • All-zero array: unscoped sample or quiet accounts.
  • Error object: decode code and message per JSON-RPC 2.0.
  • Overpayment: tighten the compute unit limit from simulation.
  • Preflight failure: no execution, no prioritization fee.

Limitations and tradeoffs of percentile-based estimation

Percentile-based estimation adds RPC calls per transaction. Each estimate requires a getRecentPrioritizationFees call, and building the transaction may require a simulation and a blockhash fetch. At scale, these calls add latency and load. Batch or cache where your workload allows, and be aware that caching a stale percentile can underprice during a spike.

A percentile cannot predict a congestion spike. It summarizes recent history, and the trailing window may not represent the next few seconds. If your application has latency-sensitive transactions, consider a higher percentile or a budget ceiling rather than relying on the sample alone.

Overpaying at scale is a real cost. Every lamport of prioritization fee multiplied across many transactions compounds. The tradeoff is between landing rate and cost; there is no single correct percentile. Tune it against your own measurements and revisit as network conditions change.

  • Extra RPC calls per transaction add latency and load.
  • A percentile summarizes history, not future congestion.
  • Overpaying compounds across transaction volume.
  • Tune the percentile against measured landing rate.

Next steps: fee reconciliation, retries, and commitment levels

After a transaction lands, reconcile the fee you paid against the block's contents. Solana getBlock rewards vs transaction fees explains how the prioritization fee enters a produced block, which closes the loop between your bid and the observed fee. Pair that with Solana commitment levels and confirmation to choose the commitment at which you consider a transaction final.

For send and retry behavior after the fee is chosen, review Solana RPC timeouts and retry strategy and Solana sendTransaction preflight and safe retries. If simulation is part of your limit-setting step, Decoding Solana simulateTransaction errors helps interpret failures.

For endpoint selection and network context, see the Solana network page and the Solana RPC API guide (RPC Assistant). The OnFinality Learn hub collects the adjacent articles in this series.

  • Reconcile paid fees against block contents.
  • Choose a commitment level for finality.
  • Handle retries after the fee is chosen.
  • Select an endpoint that supports the method and addresses argument.

Never Worry about Infrastructure Again

OnFinality takes away the heavy lifting of DevOps so you can build smarter and faster.

Get Started