Lesson 22 / 25

Gas Optimisation

Reduce gas costs without sacrificing safety or clarity.

Where gas goes

Gas costs are dominated by storage: writing a new non-zero slot costs about 20,000 gas, updating an existing slot about 5,000 (less when the slot was already accessed in the transaction), and the first read of a slot in a transaction is a "cold" access at about 2,100 gas, with later "warm" reads much cheaper. Practical optimisations: pack related small variables (such as uint128, uint64, bool, address) into one 32-byte slot by declaring them adjacently; use constant and immutable for values that never change; cache storage reads in local variables; use calldata for read-only external parameters; prefer custom errors to revert strings; emit events instead of storing data that only off-chain apps need; avoid unbounded loops; use unchecked increments in loops where overflow is impossible; and use mappings instead of arrays for lookups. Enable the compiler optimizer (and consider the IR pipeline, via_ir). Measure with forge snapshot or gas reports rather than guessing. On layer 2s, calldata posted to Ethereum can dominate costs, so data size matters. Never trade away safety or readability for small savings; audits of clever code cost more than the gas saved.

Gas cost by operation

Storage writes dwarf arithmetic; most optimisations reduce storage access.

A horizontal bar chart with a very long bar for storage writes, a medium bar for cold storage reads, and tiny bars for memory and arithmetic.
Figure 8.1 — Relative gas costs of common operations.

Before and after optimisation

Packing, immutables, cached reads and custom errors.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

// Before: 4 storage slots per stake, repeated storage reads, string errors
contract StakingBefore {
    struct Stake { uint256 amount; uint256 since; bool active; address owner; }
    mapping(uint256 => Stake) public stakes;
    address public admin;                         // never changes, but stored in storage
    uint256 public rewardRateBps = 500;           // never changes either

    constructor() { admin = msg.sender; }

    function reward(uint256 id) external view returns (uint256) {
        require(stakes[id].active, "Stake is not active right now");
        return stakes[id].amount * rewardRateBps * (block.timestamp - stakes[id].since) / (10_000 * 365 days);
    }
}

// After: 2 slots per stake, immutable/constant config, one storage read, custom error
contract StakingAfter {
    struct Stake {
        uint128 amount;     // slot 0: amount (16 bytes) + since (8 bytes) + active (1 byte)
        uint64 since;
        bool active;
        address owner;      // slot 1
    }
    mapping(uint256 => Stake) public stakes;
    address public immutable admin;
    uint256 public constant REWARD_RATE_BPS = 500;

    error InactiveStake(uint256 id);

    constructor() { admin = msg.sender; }

    function reward(uint256 id) external view returns (uint256) {
        Stake memory s = stakes[id];             // read once
        if (!s.active) revert InactiveStake(id);
        return uint256(s.amount) * REWARD_RATE_BPS * (block.timestamp - s.since) / (10_000 * 365 days);
    }
}

Measure, then optimise

Use forge snapshot --diff or a gas reporter to compare versions. Many "optimisations" found online save a few gas while making code harder to audit; storage layout and avoiding unnecessary writes matter far more.

Quick check: Which change usually saves the most gas?

  • Renaming variables
  • Using shorter comments
  • Reducing storage writes and packing variables into fewer slots
  • Adding more events
Answer

Reducing storage writes and packing variables into fewer slots — Storage operations are by far the most expensive.