Compute units: the limit that silently fails your transaction

Engineering17 min read

Every Solana transaction gets a compute budget, and exceeding it fails the transaction on chain after you have already paid for it. This covers how compute units are metered, how to measure what your instruction actually consumes, and why requesting the maximum is the wrong default.


the short version
  • Every transaction gets a compute budget. Exceeding it fails the transaction on chain, after you have paid.
  • The default is 200,000 units per instruction and the maximum is 1,400,000 per transaction.
  • The limit you request is what the scheduler prices you at, so an inflated request costs real money on every send.
  • Simulate with the limit at maximum, read unitsConsumed, add 10 to 20 percent headroom. Do not guess.

Compute units are the least glamorous part of a Solana transaction and one of the highest-leverage. Getting them wrong fails transactions you have already paid for, or quietly multiplies your priority fee by a factor of ten on every single send.

Both of those are worth an afternoon to fix.

What a compute unit is#

The Solana runtime meters execution. Every instruction a program runs costs units: arithmetic, memory access, cross-program invocations, signature verification, account loading. When a transaction exhausts its budget, the runtime aborts it.

Two properties matter and they are easy to state:

  • It is deterministic. The same instructions against the same account state consume the same units, every time. That is what makes measurement worthwhile.
  • Exceeding it is an on-chain failure. The transaction lands, aborts, and charges you. This is the one item on the standard failure list that is not a delivery problem.

The limits that apply#

LimitValueNotes
Default per instruction200,000Applied when you set no limit
Maximum per transaction1,400,000The hard ceiling you can request
Maximum per account per block12,000,000Why hot accounts throttle everyone
Cost of a budget instruction150 eachSmall, but include it in your limit

That third row is worth pausing on. There is a per-account per-block compute ceiling, so a single extremely hot account can saturate its own budget and throttle every transaction touching it regardless of what anyone paid. If your target account is that hot, no fee and no delivery path fixes it, and that is a protocol constraint rather than a tuning problem.

Why the limit costs you money#

The instinct is to request 1.4 million units and never think about it again. That instinct is expensive, for two separate reasons.

It multiplies your priority fee

Your priority fee is units × price ÷ 1,000,000. The units in that formula are the units you requested, not the units you consumed. So at the same competitive bid:

RequestedBidYou payVersus measured
1,400,00050,000 µL/CU70,000 lamports11.7x
400,00050,000 µL/CU20,000 lamports3.3x
120,000 (measured)50,000 µL/CU6,000 lamports1x

On a bot sending thousands of transactions a day, that difference is not a rounding error. Measuring compute is a fee optimisation before it is anything else.

It makes you harder to schedule

The leader packs blocks against a compute budget. A transaction claiming 1.4 million units occupies a large slice of that budget in the scheduler’s accounting, whether or not it uses it. A transaction claiming an honest 120,000 fits into gaps that the greedy one does not.

So an inflated limit costs you twice: you pay more per send, and you are marginally harder to fit into a block.

Measuring what you consume#

Simulation reports it. The detail people miss is that you must simulate with the limit already raised, or simulation aborts at the default and you measure your own ceiling.

measure.ts
1import { Connection, VersionedTransaction, PublicKey, TransactionMessage, ComputeBudgetProgram } from "@solana/web3.js";
2
3/**
4 * Measure, then set. Never guess.
5 *
6 * The trick people miss: simulate with the limit already set to the MAXIMUM,
7 * otherwise simulation aborts at the default 200k and reports that as the
8 * consumption. You are measuring the ceiling you imposed, not the work.
9 */
10export async function measureUnits(
11 rpc: Connection,
12 payer: PublicKey,
13 instructions: TransactionInstruction[],
14 lookupTables: AddressLookupTableAccount[] = [],
15): Promise<number> {
16 const { blockhash } = await rpc.getLatestBlockhash("confirmed");
17
18 const probe = new VersionedTransaction(
19 new TransactionMessage({
20 payerKey: payer,
21 recentBlockhash: blockhash,
22 instructions: [
23 ComputeBudgetProgram.setComputeUnitLimit({ units: 1_400_000 }),
24 ...instructions,
25 ],
26 }).compileToV0Message(lookupTables),
27 );
28
29 const sim = await rpc.simulateTransaction(probe, {
30 replaceRecentBlockhash: true,
31 sigVerify: false,
32 });
33
34 if (sim.value.err) {
35 throw new Error(`simulation failed: ${JSON.stringify(sim.value.err)}\n${sim.value.logs?.join("\n")}`);
36 }
37
38 // Subtract the probe's own budget instruction, which your real transaction
39 // will also carry but which you should not double count.
40 return sim.value.unitsConsumed ?? 0;
41}
42
43/** Measure once at startup, cache per instruction shape, re-measure on drift. */
44export function withHeadroom(measured: number, factor = 1.15) {
45 return Math.min(1_400_000, Math.ceil(measured * factor));
46}

The same in Rust and Python, since this usually belongs wherever your builder lives.

send.py
1from solana.rpc.api import Client
2from solana.rpc.types import TxOpts
3from solders.compute_budget import set_compute_unit_limit
4from solders.message import MessageV0
5from solders.transaction import VersionedTransaction
6
7MAX_UNITS = 1_400_000
8
9
10def measure_units(rpc: Client, payer, instructions, blockhash) -> int:
11 """Simulate at the maximum limit so the reported figure is the real work."""
12 probe = VersionedTransaction(
13 MessageV0.try_compile(
14 payer=payer.pubkey(),
15 instructions=[set_compute_unit_limit(MAX_UNITS), *instructions],
16 address_lookup_table_accounts=[],
17 recent_blockhash=blockhash,
18 ),
19 [payer],
20 )
21
22 sim = rpc.simulate_transaction(probe, sig_verify=False)
23 if sim.value.err is not None:
24 raise RuntimeError(f"simulation failed: {sim.value.err}")
25
26 return sim.value.units_consumed or 0
27
28
29def with_headroom(measured: int, factor: float = 1.15) -> int:
30 return min(MAX_UNITS, int(measured * factor) + 1)

Finding the expensive part

A total is not actionable. Program logs break consumption down per program, which tells you whether the cost is yours to optimise or the AMM’s to bear.

logs.ts
1/**
2 * Program logs report consumption per program, which is how you find the
3 * expensive part rather than just the expensive total.
4 *
5 * Look for lines of this shape in simulation output:
6 *
7 * Program <id> consumed 34918 of 1400000 compute units
8 *
9 * A swap that consumes 180k total might be 40k of your own logic and 140k in
10 * the AMM. Knowing which is which decides whether optimising is even possible.
11 */
12export function parseConsumption(logs: string[]): { program: string; units: number }[] {
13 const pattern = /^Program (\S+) consumed (\d+) of \d+ compute units$/;
14
15 return logs
16 .map((line) => line.match(pattern))
17 .filter((m): m is RegExpMatchArray => m !== null)
18 .map((m) => ({ program: m[1], units: Number(m[2]) }))
19 .sort((a, b) => b.units - a.units);
20}
21
22// Usage:
23// const sim = await rpc.simulateTransaction(tx, { sigVerify: false });
24// console.table(parseConsumption(sim.value.logs ?? []));

Choosing headroom#

Consumption is deterministic against fixed state, but state moves between your simulation and your execution. A swap crossing one more tick, an account that now needs initialising, a slightly different route. So you need headroom, and the right amount depends on how much the state can drift.

Transaction shapeHeadroomWhy
Plain SOL or SPL transfer5%Fixed cost, essentially no drift
AMM swap, constant product10 to 15%Modest state dependence
CLMM swap crossing ticks25 to 40%Tick crossings are unpredictable
Multi-hop route30%+Compounds across every hop
May create accountsMeasure both pathsCreation is a step change, not a margin
Where a transaction may or may not create an account, do not solve it with a bigger margin. Measure both paths and pick at build time based on whether the account exists. Account creation is a discrete jump of tens of thousands of units, and a percentage margin either fails to cover it or wildly overpays when it does not happen.

Typical consumption#

Rough figures for orientation only. Measure your own; these move with program versions and account state.

OperationApproximate units
SOL transfer~450
SPL token transfer~4,500
Create an associated token account~25,000
Raydium AMM v4 swap~30,000 to 60,000
Raydium CLMM swap~60,000 to 250,000
Orca Whirlpool swap~50,000 to 180,000
pump.fun buy~35,000 to 80,000
Jupiter route, two hops~150,000 to 400,000

The concentrated liquidity ranges are wide for a real reason: a swap that crosses many initialised ticks does materially more work than one that stays inside a single tick array. That is the case that needs generous headroom.

Reducing consumption#

Lower consumption means a cheaper fee at the same bid and an easier fit in a block.

  • Do not mark accounts writable that you only read. It costs units and it invents contention you did not need. Free win, twice over.
  • Use address lookup tables. They shrink the transaction and reduce account loading cost. On a route with many accounts this is substantial, and it may be the difference between fitting in 1,232 bytes and not.
  • Pre-create token accounts. Creating one inline costs about 25,000 units on the critical path. Create it once, in advance, and never pay it again.
  • Split unrelated work. Two transactions each doing one thing consume less in total than one doing both, and they can be scheduled independently.
  • Prefer simpler routes when competing. A direct pool swap consumes a fraction of a multi-hop route. When you are racing, the simpler route often wins on delivery even at a slightly worse quoted price.

Reading the failure#

When you exceed the budget you get this, on chain, having paid:

the failure
1Program <id> consumed 200000 of 200000 compute units
2Program <id> failed: exceeded CUs meter at BPF instruction
3
4// In getSignatureStatuses:
5// err: { InstructionError: [2, "ComputeBudgetExceeded"] }

The index in InstructionError tells you which instruction ran out, which combined with the per program log breakdown usually identifies the cause immediately.

This failure is distinctive because it is one of the few that does land. If you are chasing transactions that vanish entirely, this is not your cause, and the delivery diagnostic is the right place to look. Keep the two investigations separate; conflating them wastes days.

Once your compute limits are measured, revisit your fee. The two interact directly, and a correctly measured limit usually means you can bid a higher percentile for the same money. That maths is in the priority fees guide.

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