Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Chapter 21: Performance Optimization and Gas Minimization

Objective: Master performance optimization techniques for on-chain operations, maximize off-chain computation, and build efficient, low-cost EVE Frontier applications through batching, object design optimization, and Gas budget control.


Status: Engineering chapter. Main content focuses on Gas, batching, and object design optimization.

21.1 Gas Cost Model

Sui’s Gas consists of two components:

Gas Fee = (Computation Units + Storage Delta) × Gas Price
  • Computation Units: Move code execution consumption
  • Storage Delta: Net increase in on-chain storage (new bytes charged, deleted bytes refunded)

Key Insights:

  • Reading data is free (GraphQL/RPC reads don’t go on-chain)
  • Adding/removing dynamic fields has significant Gas cost
  • Emitting events is almost free (doesn’t occupy on-chain storage)

The easiest way to go wrong with Gas optimization is: many people immediately focus on “how to save a few units,” without first seeing clearly:

What’s truly expensive is often not a particular line of code, but those things your entire state model forces the system to do repeatedly.

So performance optimization is best viewed in three layers:

  • Transaction Layer Can this transaction be merged, is it doing many small actions repeatedly
  • Object Layer Are your objects too large, too hot, too centralized
  • Architecture Layer Which computations and aggregations shouldn’t be on-chain at all

21.1.1 A Reusable Gas Comparison Record Template

This chapter easily becomes just slogans. It’s recommended to record “before/after” data for at least one fixed set of operations:

OperationInefficient ApproachOptimized ApproachFields You Should Record
Two stargates online + link3 separate transactions1 PTB batchgasUsed, object writes count, total time
Market create listingAppend to large object vectorIndependent object or dynamic fieldObject size, write count, storage rebate
History recordsPersist to shared objectEmit event + off-chain indexingEvent count, object growth bytes

These numbers don’t need to pursue “absolute standard values,” but you must keep comparison records under the same environment, otherwise optimization conclusions have no persuasive power.


21.2 Batching: Do Multiple Things in One Transaction

Sui’s Programmable Transaction Blocks (PTB) allow executing multiple Move calls in one transaction:

// ❌ Inefficient: 3 separate transactions
await client.signAndExecuteTransaction({ transaction: tx_online }); // Online gate 1
await client.signAndExecuteTransaction({ transaction: tx_online }); // Online gate 2
await client.signAndExecuteTransaction({ transaction: tx_link });   // Link gates

// ✅ Efficient: 1 transaction completes all operations
const tx = new Transaction();

// Borrow OwnerCap (once)
const [ownerCap1, receipt1] = tx.moveCall({ target: `${PKG}::character::borrow_owner_cap`, ... });
const [ownerCap2, receipt2] = tx.moveCall({ target: `${PKG}::character::borrow_owner_cap`, ... });

// Execute all operations
tx.moveCall({ target: `${PKG}::gate::online`, arguments: [gate1, ownerCap1, ...] });
tx.moveCall({ target: `${PKG}::gate::online`, arguments: [gate2, ownerCap2, ...] });
tx.moveCall({ target: `${PKG}::gate::link`,   arguments: [gate1, gate2, ...] });

// Return OwnerCap
tx.moveCall({ target: `${PKG}::character::return_owner_cap`, arguments: [..., receipt1] });
tx.moveCall({ target: `${PKG}::character::return_owner_cap`, arguments: [..., receipt2] });

await client.signAndExecuteTransaction({ transaction: tx });
// Save 2/3 of base Gas fee!

21.2.1 How to Record a Real Gas Comparison

  1. First fix inputs: same network, same object count, same batch of operations
  2. Record inefficient version execution results: digest, gasUsed, write object count in effects
  3. Then execute PTB version, record same fields
  4. Organize results into a comparison table, write into your release or optimization notes

Recommended to record at least these fields:

- digest
- computationCost
- storageCost
- storageRebate
- nonRefundableStorageFee
- changedObjects count

PTB is not “merge everything you can”

Batching is powerful, but it’s not best to blindly stuff all actions into one transaction.

Suitable for merging:

  • Steps that are already strongly related
  • Flows that must atomically succeed or fail together
  • Operations borrowing the same type of permission object multiple times

Not necessarily suitable for over-merging:

  • Stuffing too much unrelated logic into one transaction
  • Hard to pinpoint problems once failure occurs
  • Gas budget and computation become unpredictable

So PTB’s goal is not “maximize length,” but “converge a flow that should truly be atomic.”


21.3 Object Design Optimization

Principle One: Avoid Large Objects

// ❌ Put all data in one object (max 250KB)
public struct BadMarket has key {
    id: UID,
    listings: vector<Listing>,     // Object grows as products increase
    bid_history: vector<BidRecord>, // History data grows infinitely
}

// ✅ Use dynamic fields or independent objects for distributed storage
public struct GoodMarket has key {
    id: UID,
    listing_count: u64,  // Only store counter
    // Specific Listing stored via dynamic fields: df::add(id, item_id, listing)
}

Principle Two: Delete Objects No Longer Needed (Get Storage Rebate)

// After auction ends, delete Listing to get Gas refund
public fun end_auction(auction: DutchAuction) {
    let DutchAuction { id, .. } = auction;
    id.delete(); // Delete object → storage rebate
}

// After claiming, delete DividendClaim object
public fun close_claim_record(record: DividendClaim) {
    let DividendClaim { id, .. } = record;
    id.delete();
}

Principle Three: Use u8/u16 Instead of u64 for Small Integers

// ❌ Waste space
public struct Config has key {
    id: UID,
    tier: u64,     // Only stores 1-5, but occupies 8 bytes
    status: u64,   // Only stores 0-3, but occupies 8 bytes
}

// ✅ Compact storage
public struct Config has key {
    id: UID,
    tier: u8,      // Only occupies 1 byte
    status: u8,    // Only occupies 1 byte
}

Why is object design almost always the root cause of performance issues?

Because on Sui, performance and object model are tied together:

  • Larger objects mean heavier reads/writes
  • Hotter shared objects mean higher contention
  • More centralized state means harder to scale

So many performance optimizations end up not “rewriting algorithms,” but “refactoring object boundaries.”

A very practical criterion

As long as an object has both of these characteristics, you should start to be alert:

  • Frequently written
  • Still growing

Such objects will almost certainly become performance hotspots.


21.4 Off-Chain Computation, On-Chain Verification

Golden Rule: All computation that doesn’t need enforcement should be done off-chain.

// ❌ Sort on-chain (extremely Gas-consuming)
public fun get_top_bidders(auction: &Auction, n: u64): vector<address> {
    let mut sorted = vector::empty<BidRecord>();
    // ... O(n²) sorting, executed on-chain every time
}

// ✅ Store raw data on-chain, sort off-chain
public fun get_bid_at(auction: &Auction, index: u64): BidRecord {
    *df::borrow<u64, BidRecord>(&auction.id, index)
}
// dApp or backend reads all bids, sorts in memory, displays leaderboard

Complex Routing Computation Done Off-Chain

// Example: Stargate logistics routing (off-chain optimal path calculation)
function findOptimalRoute(
  start: string,
  end: string,
  gateGraph: Map<string, string[]>, // gate_id → [connected_gate_ids]
): string[] {
  // Dijkstra or other path algorithms, executed in dApp/backend
  // After calculating optimal path, only submit final jump operations on-chain
  return dijkstra(gateGraph, start, end);
}

Off-chain computation is not cutting corners, but proper division of labor

Many things suitable for off-chain are essentially not “unimportant,” but rather:

  • Results need display, but don’t need on-chain enforcement
  • Algorithm is complex, but ultimately only need to submit a conclusion
  • Can be recalculated, cached, replaced

If such work is forced on-chain, it only raises cost and failure surface together.

When must you verify on-chain?

When results affect:

  • Asset ownership
  • Permission authorization
  • Amount settlement
  • Scarce resource allocation

Then you must put key conclusions back on-chain for verification, not just trust off-chain calculations.


21.5 Gas Budget Setting

const tx = new Transaction();

// Set Gas budget cap (prevent unexpected excess consumption)
tx.setGasBudget(10_000_000); // 10 SUI cap

// Or use dryRun to estimate Gas
const estimate = await client.dryRunTransactionBlock({
  transactionBlock: await tx.build({ client }),
});
console.log("Estimated Gas:", estimate.effects.gasUsed);

The most valuable part of dryRun is not “estimating a number,” but discovering model issues early

If a transaction’s dry run results already show:

  • Many write objects
  • Abnormally high storage cost
  • Very little refund

That usually means the problem is not in the budget, but in the structure itself.


21.6 Parallel Execution: Contention-Free Shared Object Design

Sui can execute transactions operating on different objects in parallel. Contention for the same shared object causes sequential execution:

// ❌ All users contend for the same Market object
Market (shared) ← All purchase transactions need write lock → Sequential execution
(High traffic causes queue buildup, latency increases)

// ✅ Sharding design (multiple SubMarkets)
Market_Shard_0 (shared) ← Transactions where item type_id % 4 == 0
Market_Shard_1 (shared) ← Transactions where item type_id % 4 == 1
Market_Shard_2 (shared) ← Transactions where item type_id % 4 == 2
Market_Shard_3 (shared) ← Transactions where item type_id % 4 == 3
(4 shards execute in parallel, throughput ×4)
// Shard routing
public fun buy_item_sharded(
    shards: &mut vector<MarketShard>,
    item_type_id: u64,
    payment: Coin<SUI>,
    ctx: &mut TxContext,
) {
    let shard_index = item_type_id % vector::length(shards);
    let shard = vector::borrow_mut(shards, shard_index);
    buy_from_shard(shard, item_type_id, payment, ctx);
}

The most important question in concurrency design

Not “can it be parallel,” but:

In my business flow, which states must contend for the same shared object, and which can naturally be split?

For example, in a market system, common dimensions that can be split include:

  • Item type
  • Region
  • Tenant
  • Time bucket

As long as the split dimension is chosen correctly, throughput typically improves significantly.

Sharding also has costs

Don’t treat sharding as a free lunch. It brings:

  • More complex query aggregation
  • More complex routing logic
  • Frontend and indexing layers need to know sharding rules

So sharding is a clear trade-off of “increasing system complexity for throughput,” not the default option.


🔖 Chapter Summary

Optimization TechniqueSavings Ratio
PTB batching (merge multiple transactions)30-70% base fee
Off-chain computation, on-chain verificationEliminate complex computation Gas
Delete obsolete objectsGet storage rebate
Compact data types (u8 vs u64)Reduce object size
Shard shared objectsIncrease concurrent throughput

📚 Further Reading