10 Gas Optimization Tips for Smart Contracts

Learn essential strategies for optimizing gas costs in smart contracts, enhancing performance and user experience on the blockchain.

Learn essential strategies for optimizing gas costs in smart contracts, enhancing performance and user experience on the blockchain.

Andre Costa

Published on:

Mar 8, 2025

Cryptocurrency

Want to save on Ethereum gas fees? Here's how to write efficient smart contracts that reduce costs and improve performance. Gas optimization is crucial for blockchain developers to lower fees, avoid block limits, and enhance the user experience. Below are 10 practical strategies you can start using today:

  • Choose the Right Data Types: Use smaller data types and pack variables into storage slots efficiently.

  • Reduce Loop Complexity: Cache array lengths, break loops early, and use batch processing.

  • Use Memory vs. Storage Correctly: Minimize expensive storage operations by leveraging memory for intermediate calculations.

  • Write Better Function Modifiers: Simplify modifiers, cache storage reads, and combine related checks.

  • Apply Short-Circuit Logic: Order conditions by gas cost and use logical operators that stop evaluating early.

  • Simplify Math Operations: Use bitwise operations, fixed-point arithmetic, and unchecked blocks for cheaper calculations.

  • Group Multiple Transactions: Combine tasks into batches or use multicall patterns to save gas.

  • Store Data in Events: Log non-essential data in events instead of costly on-chain storage.

  • Minimize Contract Calls: Cache external values, use internal functions, and optimize architecture to avoid redundant calls.

  • Use Gas-Saving Patterns: Apply pull-over-push, minimal proxies, bitmaps, and storage packing for further savings.

These techniques can significantly cut gas costs while maintaining functionality and security. Whether you're building NFT minting contracts or DeFi protocols, these strategies will help you scale efficiently.

Optimization Tip

Key Benefit

Example

Right Data Types

Reduce storage costs

Pack uint8 variables into a single slot

Reduce Loop Complexity

Avoid block gas limits

Cache array length in loops

Memory vs. Storage

Lower expensive storage reads/writes

Use memory for temporary calculations

Better Function Modifiers

Simplify logic and save gas

Cache storage variables in modifiers

Short-Circuit Logic

Evaluate fewer conditions

Order checks by gas cost

Simplify Math Operations

Reduce expensive math operations

Use bitwise shifts for powers of 2

Group Transactions

Minimize overhead of multiple calls

Batch token transfers

Store Data in Events

Avoid unnecessary on-chain storage

Log transfer history in events

Minimize Contract Calls

Reduce redundant external calls

Cache external data locally

Gas-Saving Patterns

Advanced optimizations for efficiency

Use minimal proxies or bitmaps for flags

Start implementing these tips to make your smart contracts leaner and cheaper to execute. Keep reading for detailed examples and code snippets for each strategy!

Gas Optimization in Solidity: 10 tips

Solidity

1. Choose the Right Data Types

Picking the appropriate data types is crucial for cutting gas costs, especially when using Solidity's storage slot packing.

While smaller types like uint8 or uint16 might seem like obvious choices to save gas, the Ethereum Virtual Machine (EVM) can pack these smaller types into a single 32-byte storage slot when grouped efficiently. Here's an example to illustrate:

// Inefficient: Uses 3 storage slots
contract Inefficient {
    uint256 a;  // Slot 0
    uint256 b;  // Slot 1
    uint256 c;  // Slot 2
}

// Efficient: Uses 1 storage slot
contract Efficient {
    uint8 a;    // Slot 0
    uint8 b;    // Slot 0
    uint8 c;    // Slot 0
}

To optimize storage and gas usage, keep these strategies in mind:

  • Group related variables: Arrange smaller variables together to maximize the use of storage slots.

  • Use fixed-size arrays: These can be more efficient than dynamic arrays, especially when storing small, consistent data points.

  • Leverage mappings: For more complex data structures, mappings can be a better choice.

These techniques are especially valuable in contracts that handle high transaction volumes, like token contracts. For instance, optimizing data types in a contract processing millions of transactions can lead to significant gas savings over time. But remember, gas optimization should never come at the expense of security. If your contract requires handling large numbers, don’t compromise by using smaller uint types, as this could lead to overflow risks or other vulnerabilities.

Up next, we’ll look at how simplifying loops can further reduce gas costs.

2. Reduce Loop Complexity

Loops can quickly eat up gas, especially when dealing with large arrays or nested iterations. Optimizing them is key to cutting down gas costs and avoiding block gas limits.

// Inefficient implementation
function processArray(uint256[] memory items) public {
    for (uint256 i = 0; i < items.length; i++) {
        for (uint256 j = 0; j < items.length; j++) {
            // O(n²) complexity
            doSomething(items[i], items[j]);
        }
    }
}

// Optimized implementation
function processArray(uint256[] memory items) public {
    uint256 length = items.length;
    for (uint256 i = 0; i < length; i++) {
        doSomething(items[i], items[i]);
    }
}

Here’s how to make your loops more efficient:

  • Cache Array Length: Store the array length in a local variable to avoid recalculating it in each iteration.

  • Break Early: Exit the loop as soon as the desired condition is met:

function findFirstMatch(uint256[] memory array, uint256 target) public pure returns (uint256) {
    uint256 length = array.length;
    for (uint256 i = 0; i < length; i++) {
        if (array[i] == target) {
            return i;
        }
    }
    return type(uint256).max;
}

  • Unchecked Math: Use unchecked blocks to save gas when overflow isn’t a concern:

function iterateUnchecked(uint256[] memory array) public pure {
    uint256 length = array.length;
    for (uint256 i = 0; i < length;) {
        // Process array[i]
        unchecked { ++i; }
    }
}

  • Batch Processing: Process large datasets in smaller chunks to keep operations manageable:

function processBatch(uint256[] memory items, uint256 batchSize) public {
    uint256 length = items.length;
    uint256 currentIndex = 0;

    while (currentIndex < length) {
        uint256 endIndex = currentIndex + batchSize;
        if (endIndex > length) {
            endIndex = length;
        }

        for (uint256 i = currentIndex; i < endIndex; i++) {
            // Process items[i]
        }

        currentIndex = endIndex;
    }
}

Always test your loops with realistic data sizes to analyze gas usage effectively. For more advanced tips, check out My Web3 Startup for smart contract optimization insights.

3. Use Memory vs Storage Correctly

Memory operations are far cheaper than storage operations - storage can cost up to 100 times more gas. Let’s break it down with an example:

// Expensive: Multiple reads from storage
function sumArrayStorage(uint256[] storage numbers) internal view returns (uint256) {
    uint256 sum = 0;
    for (uint256 i = 0; i < numbers.length; i++) {
        sum += numbers[i];  // Each iteration reads from storage
    }
    return sum;
}

// Optimized: Single copy to memory
function sumArrayMemory(uint256[] storage numbers) internal view returns (uint256) {
    uint256[] memory memoryArray = numbers;  // Copy storage to memory once
    uint256 sum = 0;
    uint256 length = memoryArray.length;
    for (uint256 i = 0; i < length; i++) {
        sum += memoryArray[i];  // Read from memory
    }
    return sum;
}

Best Practices for Efficiency

  • Cache Storage Variables

By caching storage references in a local variable, you reduce repeated storage access, which saves gas.

// Inefficient
function updateUserData(address user) public {
    users[user].lastUpdate = block.timestamp;
    users[user].points += 100;
    users[user].level += 1;
}

// Optimized
function updateUserData(address user) public {
    UserData storage userData = users[user];  // Cache the reference
    userData.lastUpdate = block.timestamp;
    userData.points += 100;
    userData.level += 1;
}

  • Use Memory for Intermediate Calculations

Perform calculations in memory when possible, then update storage once at the end. This minimizes costly storage operations.

function processLargeArray(uint256[] storage data) public {
    uint256[] memory tempArray = new uint256[](data.length);
    uint256 length = data.length;

    for (uint256 i = 0; i < length; i++) {
        tempArray[i] = data[i] * 2;  // Work in memory
    }

    for (uint256 i = 0; i < length; i++) {
        data[i] = tempArray[i];  // Update storage once
    }
}

  • Return Memory Arrays Instead of Storage

Returning storage arrays directly is expensive. Instead, copy the data to memory first.

// Expensive: Returns storage array
function getItems() public view returns (uint256[] storage) {
    return items;
}

// Optimized: Returns memory array
function getItems() public view returns (uint256[] memory) {
    uint256[] memory memoryItems = new uint256[](items.length);
    for (uint256 i = 0; i < items.length; i++) {
        memoryItems[i] = items[i];
    }
    return memoryItems;
}

Key Considerations

Always evaluate whether data needs to be persistent. If not, memory is usually the better choice. However, be mindful that copying large datasets to memory still incurs gas costs, so use this approach wisely.

Operation Type

Storage Cost

Memory Cost

Best Use Case

Read

~200

3

Frequent reads

Write

~5,000

3

Persistent data

Array Access

~200 per element

3 per element

Temporary data

Array Copy

~20,000+

~100

One-time processing

Next, we’ll dive into optimizing function modifiers to save even more gas.

4. Write Better Function Modifiers

Function modifiers can drive up gas costs if not handled properly. Here's how to fine-tune them for better efficiency.

Keep Modifier Logic Simple

Avoid cramming too much logic into modifiers. Instead, shift complex operations into separate functions.

// Less efficient
modifier checkUserStatus(address user) {
    require(users[user].isActive, "User not active");
    require(users[user].balance > 0, "Insufficient balance");
    require(block.timestamp > users[user].cooldown, "In cooldown");
    _;
}

// More efficient: Offload logic to a function
function validateUserStatus(address user) internal view returns (bool) {
    return users[user].isActive &&
           users[user].balance > 0 &&
           block.timestamp > users[user].cooldown;
}

modifier checkUserStatus(address user) {
    require(validateUserStatus(user), "Invalid user status");
    _;
}

Cache Repeated Storage Reads

Modifiers that repeatedly access the same storage variables waste gas. Cache these values to reduce redundant reads.

// Less efficient: Multiple storage reads
modifier validateTransaction(uint256 amount) {
    require(balances[msg.sender] >= amount, "Insufficient balance");
    require(balances[msg.sender] - amount >= minBalance, "Below min balance");
    _;
}

// More efficient: Cache storage value
modifier validateTransaction(uint256 amount) {
    uint256 userBalance = balances[msg.sender];
    require(userBalance >= amount, "Insufficient balance");
    require(userBalance - amount >= minBalance, "Below min balance");
    _;
}

Pass Only Essential Parameters

Modifiers with too many parameters increase calldata costs. Stick to what's absolutely necessary.

// Less efficient: Extra parameters
modifier checkPermissions(
    address user,
    uint256 role,
    uint256 timestamp,
    bool isActive
) {
    require(hasRole(user, role), "Invalid role");
    _;
}

// More efficient: Only needed parameters
modifier checkRole(uint256 role) {
    require(hasRole(msg.sender, role), "Invalid role");
    _;
}

Combine Related Modifiers

If multiple modifiers perform similar checks, merge them into one to cut down on redundant operations.

// Less efficient
function transfer(address to, uint256 amount) public
    checkActive()
    checkBalance(amount)
    checkLimits(amount)
{
    // Transfer logic
}

// More efficient: Combined modifier
modifier validateTransfer(uint256 amount) {
    require(isActive(), "Contract paused");
    require(balanceOf(msg.sender) >= amount, "Insufficient balance");
    require(amount <= transferLimit, "Exceeds limit");
    _;
}

function transfer(address to, uint256 amount) public
    validateTransfer(amount)
{
    // Transfer logic
}

Gas Cost Comparison

Implementation

Average Gas Cost

Savings

Multiple Modifiers

15,000

Baseline

Combined Modifier

9,000

40%

Cached Storage Reads

7,500

50%

Optimized Parameters

6,000

60%

Consider Using Internal Functions

For complex logic, internal functions can sometimes be a better choice than modifiers. They offer greater flexibility and may use less gas.

// Internal function instead of modifier
function _validateOperation(uint256 amount) internal view {
    if (!isActive()) revert("Contract paused");
    if (balanceOf(msg.sender) < amount) revert("Insufficient balance");
    if (amount > transferLimit) revert("Exceeds limit");
}

function transfer(address to, uint256 amount) public {
    _validateOperation(amount);
    // Transfer logic
}

Modifiers execute code both before and after the main function body, increasing deployment and call costs. Use them wisely to keep your smart contracts lean and efficient. Up next, we'll look at ways to further optimize smart contract execution.

5. Apply Short-Circuit Logic

Short-circuit logic in Solidity can help cut down gas usage. This method takes advantage of how the logical operators && (AND) and || (OR) evaluate conditions.

How Short-Circuit Evaluation Works

In Solidity, logical operators evaluate conditions from left to right and stop as soon as the result is clear:

  • && stops as soon as it finds a false.

  • || stops as soon as it finds a true.

// Less efficient: All conditions are evaluated
function validateTransfer(uint256 amount) public view returns (bool) {
    bool hasBalance = checkBalance(amount);
    bool withinLimit = checkLimit(amount);
    bool notPaused = !isPaused();
    return hasBalance && withinLimit && notPaused;
}

// More efficient
function validateTransfer(uint256 amount) public view returns (bool) {
    return !isPaused() && // Least expensive check first
           checkBalance(amount) &&
           checkLimit(amount);
}

Arrange Conditions by Gas Cost

Order your conditions based on their gas usage, starting with the cheapest.

// Original implementation
function processPayment(uint256 amount) public {
    require(
        validateComplexCalculation(amount) && // 5,000 gas
        balances[msg.sender] >= amount && // 2,100 gas
        !paused // 800 gas
    , "Payment failed");
}

// Reordered conditions
function processPayment(uint256 amount) public {
    require(
        !paused && // 800 gas
        balances[msg.sender] >= amount && // 2,100 gas
        validateComplexCalculation(amount) // 5,000 gas
    , "Payment failed");
}

Gas Cost Breakdown

Implementation

Best Case

Worst Case

Without Short-Circuit

7,900 gas

7,900 gas

With Short-Circuit

800 gas

7,900 gas

Optimized Ordering

800 gas

7,900 gas

Use Custom Errors for Efficiency

Custom errors provide a more gas-efficient way to handle failed conditions.

// Custom errors
error ContractPaused();
error InsufficientBalance();
error InvalidCalculation();

function processPayment(uint256 amount) public {
    if (paused) revert ContractPaused();
    if (balances[msg.sender] < amount) revert InsufficientBalance();
    if (!validateComplexCalculation(amount)) revert InvalidCalculation();

    // Process payment
}

Managing Complex Conditions

When dealing with multiple checks, group related conditions for clarity and efficiency:

function executeMultiStepOperation(uint256 amount) public {
    // Initial checks
    if (!(isActive() && !paused && amount > 0)) {
        revert("Invalid state");
    }

    // Detailed validations
    if (!(validateBalance(amount) && validateLimits(amount))) {
        revert("Validation failed");
    }

    // Execute operation
}

6. Simplify Math Operations

Gas optimization in smart contracts often comes down to refining math operations. Complex calculations can consume more gas, so opting for simpler methods can help save costs while maintaining the desired functionality.

Use Bitwise Operations

Bitwise operations are an efficient alternative to division and multiplication by powers of 2. Here's how you can replace them:

// Divide by 2:
uint256 result = number / 2;

// Use a right shift (>>):
uint256 result = number >> 1;

// Multiply by 2:
uint256 result = number * 2;

// Use a left shift (<<):
uint256 result = number << 1;

Optimize Power Calculations

Exponentiation can be expensive in terms of gas. Replace it with direct multiplication when possible:

// Using exponentiation:
uint256 result = x ** 2;

// Replace with:
uint256 result = x * x;

// For higher powers:
uint256 result = x * x * x;

Switch to Fixed-Point Arithmetic

When working with decimals, fixed-point arithmetic is a better choice than floating-point operations. It ensures accuracy while keeping gas usage in check:

// Define a scaling factor:
uint256 constant SCALING_FACTOR = 1e18;

// Scaled multiplication:
function multiplyFixed(uint256 a, uint256 b) internal pure returns (uint256) {
    return (a * b) / SCALING_FACTOR;
}

// Scaled division:
function divideFixed(uint256 a, uint256 b) internal pure returns (uint256) {
    return (a * SCALING_FACTOR) / b;
}

Reduce Precision Where Possible

Avoid using higher precision than needed. This can simplify calculations and reduce gas costs:

// High precision (unnecessary for 2 decimal places):
uint256 percentage = (value * 10000) / total;

// Lower precision (sufficient for 2 decimal places):
uint256 percentage = (value * 100) / total;

Group Calculations Together

Instead of updating state multiple times, batch calculations can reduce the number of state changes, saving gas:

// Multiple state changes (less efficient):
function updateMultipleRates(uint256[] memory rates) public {
    for (uint256 i = 0; i < rates.length; i++) {
        rates[i] = rates[i] * 2;
        emit RateUpdated(i, rates[i]);
    }
}

// Batch calculations (more efficient):
function updateMultipleRates(uint256[] memory rates) public {
    uint256[] memory newRates = new uint256[](rates.length);
    for (uint256 i = 0; i < rates.length; i++) {
        newRates[i] = rates[i] * 2;
    }
    emit RatesUpdated(newRates);
}

Leverage Unchecked Math

For Solidity 0.8.0 and newer, you can use unchecked blocks to skip overflow checks when they aren't necessary. This can further reduce gas usage:

function sumArray(uint256[] memory values) public pure returns (uint256) {
    uint256 total = 0;
    unchecked {
        for (uint256 i = 0; i < values.length; i++) {
            total += values[i];
        }
    }
    return total;
}

Using unchecked math is particularly useful when you're confident that overflow isn't possible, ensuring both safety and efficiency.

7. Group Multiple Transactions

Combining multiple operations into a single transaction is a practical way to save on gas costs. By reducing the overhead of executing separate transactions, smart contracts can operate more efficiently.

Batch Processing

Batch processing allows you to handle multiple tasks in one go. Here's an example of optimizing token transfers:

// Less efficient:
function transferTokens(address[] calldata recipients, uint256 amount) public {
    for (uint256 i = 0; i < recipients.length; i++) {
        token.transfer(recipients[i], amount);
    }
}

// More efficient:
function batchTransferTokens(
    address[] calldata recipients,
    uint256[] calldata amounts
) public {
    require(recipients.length == amounts.length, "Length mismatch");
    uint256 totalAmount;

    for (uint256 i = 0; i < recipients.length; i++) {
        totalAmount += amounts[i];
    }

    require(token.balanceOf(msg.sender) >= totalAmount, "Insufficient balance");

    for (uint256 i = 0; i < recipients.length; i++) {
        token.transfer(recipients[i], amounts[i]);
    }
}

By processing multiple transfers in one transaction, you minimize redundant operations and reduce gas usage.

Multicall Implementation

A multicall approach combines independent calls into one, further cutting costs. Here's how it works:

contract MultiCall {
    struct Call {
        address target;
        bytes callData;
    }

    function aggregate(Call[] memory calls) public returns (bytes[] memory) {
        bytes[] memory returnData = new bytes[](calls.length);

        for (uint256 i = 0; i < calls.length; i++) {
            (bool success, bytes memory ret) = calls[i].target.call(
                calls[i].callData
            );
            require(success, "Call failed");
            returnData[i] = ret;
        }

        return returnData;
    }
}

This approach is useful for executing multiple independent contract calls in a single transaction.

State Updates Optimization

When updating multiple state variables, consolidating them into one operation can save gas:

struct UserProfile {
    string name;
    uint256 age;
    string location;
}

function updateUserProfileBatch(UserProfile memory profile) public {
    userProfiles[msg.sender] = profile;
}

This method avoids multiple state write operations, which are typically expensive.

Bulk Data Processing

Handling large sets of data in one transaction is another way to optimize gas usage. Here's an example:

struct TokenAllocation {
    address recipient;
    uint256 amount;
}

function bulkAllocateTokens(TokenAllocation[] memory allocations) public {
    uint256 totalAmount;

    for (uint256 i = 0; i < allocations.length; i++) {
        totalAmount += allocations[i].amount;
    }

    require(token.balanceOf(address(this)) >= totalAmount, "Insufficient tokens");

    for (uint256 i = 0; i < allocations.length; i++) {
        token.transfer(
            allocations[i].recipient,
            allocations[i].amount
        );
    }
}

This approach ensures that all allocations are processed together, saving both time and gas.

Memory Management

Efficient memory allocation is key when working with large datasets. Pre-allocating arrays can help avoid unnecessary resizing, which consumes extra gas:

function processLargeDataSet(uint256[] memory data) public {
    uint256[] memory results = new uint256[](data.length);

    for (uint256 i = 0; i < data.length; i++) {
        results[i] = performCalculation(data[i]);
    }

    emit BatchProcessingComplete(results);
}

By managing memory effectively, you can further optimize your batch operations.

For more insights and tailored blockchain solutions, check out My Web3 Startup – Web3, Blockchain, Crypto Design & Development Services.

8. Store Data in Events

To reduce gas costs, consider storing non-essential data in events rather than using on-chain storage. Events provide an efficient way to log data off-chain, making transactions cheaper while still keeping the information accessible. This method works well alongside other gas-saving strategies by shifting data logging away from costly storage.

Why Use Events for Data Storage?

Events are stored in transaction logs instead of contract storage, which makes them much more efficient. Here's an example of how to implement this approach:

contract TokenTracker {
    // Less efficient: Storing transfer history in a mapping
    mapping(address => uint256[]) private transferHistory;

    // More efficient: Using events
    event Transfer(
        address indexed from,
        address indexed to,
        uint256 amount,
        uint256 timestamp
    );

    function transfer(address to, uint256 amount) public {
        // ... transfer logic ...

        // Emit an event instead of storing in a mapping
        emit Transfer(msg.sender, to, amount, block.timestamp);
    }
}

Indexed Parameters for Better Efficiency

You can make event logging even more efficient by indexing up to three parameters per event. Indexed parameters allow for quicker filtering and searching.

contract AssetManager {
    // Event with indexed parameters
    event AssetTransfer(
        address indexed sender,
        address indexed recipient,
        bytes32 indexed assetId,
        uint256 timestamp,
        string metadata
    );

    function transferAsset(
        address recipient,
        bytes32 assetId,
        string memory metadata
    ) public {
        // ... transfer logic ...

        emit AssetTransfer(
            msg.sender,
            recipient,
            assetId,
            block.timestamp,
            metadata
        );
    }
}

Using Events for Historical Data

For data that doesn’t affect on-chain logic but needs to be logged, events offer a lightweight alternative to storage:

contract UserRegistry {
    // Current state stored on-chain
    mapping(address => bool) public isRegistered;

    // Historical data logged as events
    event UserAction(
        address indexed user,
        string action,
        uint256 timestamp
    );

    function registerUser() public {
        require(!isRegistered[msg.sender], "Already registered");
        isRegistered[msg.sender] = true;

        emit UserAction(
            msg.sender,
            "REGISTERED",
            block.timestamp
        );
    }
}

Tips for Optimizing Events

To save even more gas, keep these strategies in mind when designing events:

  • Use bytes32 instead of string for fixed-length data.

  • Limit indexed parameters to the most critical fields for searching.

  • Group related data into a single event instead of emitting multiple events.

contract OptimizedEvents {
    // Less efficient example
    event UserUpdate(
        address indexed user,
        string field,
        string value
    );

    // More efficient example
    event UserBatchUpdate(
        address indexed user,
        bytes32[] fields,
        bytes32[] values,
        uint256 timestamp
    );

    function updateUserData(
        bytes32[] memory fields,
        bytes32[] memory values
    ) public {
        require(fields.length == values.length, "Length mismatch");

        emit UserBatchUpdate(
            msg.sender,
            fields,
            values,
            block.timestamp
        );
    }
}

Accessing Event Data

Event data can be retrieved using Web3 libraries or blockchain explorers. Off-chain services can index and query this data efficiently. Here’s an example using Web3.js:

// Web3.js event listener example
contract.events.UserBatchUpdate({
    filter: { user: userAddress },
    fromBlock: 'latest'
})
.on('data', function(event) {
    console.log('User data updated:', event.returnValues);
})
.on('error', console.error);

Keep in mind that event data is only available off-chain, so it’s not suitable for logic that needs to run directly on the blockchain.

9. Minimize Contract Calls

Reducing external contract calls can significantly lower gas fees by cutting down on extra computation and state changes. Let's explore some practical strategies, including batch operations, caching, and optimizing contract architecture.

Batch Operations

Combining multiple operations into a single transaction helps reduce gas costs by minimizing overhead. Here's an example:

contract TokenDistributor {
    IERC20 public token;

    // Individual transfers
    function distributeTokensIndividually(
        address[] memory recipients,
        uint256[] memory amounts
    ) public {
        for (uint256 i = 0; i < recipients.length; i++) {
            token.transfer(recipients[i], amounts[i]);
        }
    }

    // Batch transfer
    function distributeTokensBatch(
        address[] memory recipients,
        uint256[] memory amounts
    ) public {
        require(
            recipients.length == amounts.length,
            "Length mismatch"
        );

        uint256[] memory data = new uint256[](recipients.length * 2);
        for (uint256 i = 0; i < recipients.length; i++) {
            data[i * 2] = uint256(uint160(recipients[i]));
            data[i * 2 + 1] = amounts[i];
        }

        token.batchTransfer(data);
    }
}

By grouping related transfers, the batch method reduces the number of transactions and associated gas fees.

Multi-Call Patterns

As mentioned earlier (see section 7), multi-call patterns allow you to execute multiple operations in a single transaction, cutting down on repeated overhead.

Cache External Values

Instead of repeatedly calling external contracts, cache their values when possible. This approach reduces redundant calls and saves gas.

contract PriceAggregator {
    function calculateTotal(uint256[] memory tokenIds) public view returns (uint256) {
        uint256[] memory prices = IPriceOracle(oracleAddress).getPrices(tokenIds);
        uint256 total = 0;
        for (uint256 i = 0; i < prices.length; i++) {
            total += prices[i];
        }
        return total;
    }
}

Here, prices are fetched once and stored locally, avoiding multiple external calls.

Internal vs. External Function Calls

Internal function calls are cheaper since they compile to simple EVM jumps, unlike external calls that involve more computation.

contract FunctionCallOptimizer {
    function externalFunction() external returns (uint256) {
        return someCalculation();
    }

    function internalFunction() internal returns (uint256) {
        return someCalculation();
    }

    function someCalculation() internal pure returns (uint256) {
        return 42;
    }
}

Whenever possible, use internal calls for operations that don't require external access.

Contract Architecture Optimization

Organize your contracts to group related functionalities and reduce unnecessary external interactions. For instance:

contract OptimizedRegistry {
    struct UserData {
        uint256 balance;
        uint256 stake;
        bool isActive;
    }

    mapping(address => UserData) private userData;

    function updateUserStatus(
        uint256 newBalance,
        uint256 newStake,
        bool active
    ) public {
        userData[msg.sender] = UserData(
            newBalance,
            newStake,
            active
        );
    }
}

This structure consolidates user data updates, minimizing external calls and simplifying the contract's logic.

10. Use Gas-Saving Patterns

Leverage proven gas-saving design patterns to make your smart contracts more efficient. Building on earlier optimization techniques, these patterns help fine-tune your contract design for lower gas costs.

Pull-Over-Push Pattern

This pattern changes how value transfers are handled. By shifting the responsibility to the recipient, you can lower gas costs and reduce risks.

contract RewardDistributor {
    mapping(address => uint256) public rewards;

    // Push pattern (less efficient)
    function distributeRewards(address[] memory recipients) public {
        for (uint256 i = 0; i < recipients.length; i++) {
            (bool success, ) = recipients[i].call{
                value: rewards[recipients[i]]
            }("");
            require(success, "Transfer failed");
            rewards[recipients[i]] = 0;
        }
    }

    // Pull pattern (more efficient)
    function claimReward() public {
        uint256 amount = rewards[msg.sender];
        require(amount > 0, "No rewards available");
        rewards[msg.sender] = 0;
        (bool success, ) = msg.sender.call{value: amount}("");
        require(success, "Transfer failed");
    }
}

Minimal Proxy Pattern

Also called EIP-1167, this pattern allows you to create lightweight contract clones that share logic with a master contract. This approach can cut deployment costs by up to 85%.

contract MinimalProxy {
    function clone(address implementation) external returns (address instance) {
        assembly {
            let ptr := mload(0x40)
            mstore(ptr, 0x3d602d80600a3d3981f3363d3d373d3d3d363d73000000000000000000000000)
            mstore(add(ptr, 0x14), shl(96, implementation))
            mstore(add(ptr, 0x28), 0x5af43d82803e903d91602b57fd5bf30000000000000000000000000000000000)
            instance := create(0, ptr, 0x37)
        }
        require(instance != address(0), "Create failed");
    }
}

Bitmap Pattern

Bitmaps are a great way to store multiple boolean states efficiently, especially when dealing with numerous flags.

contract UserPermissions {
    mapping(address => uint256) private permissions;

    // Set multiple permissions in one operation
    function setPermissions(uint256 _permissions) public {
        permissions[msg.sender] = _permissions;
    }

    // Check specific permission using bit operations
    function hasPermission(uint8 permissionBit) public view returns (bool) {
        return (permissions[msg.sender] & (1 << permissionBit)) != 0;
    }
}

Uncheck Pattern

If you're performing operations where overflow/underflow checks aren't necessary, using unchecked blocks can reduce gas usage.

contract UncheckedCounter {
    uint256 public counter;

    // Regular increment (more gas)
    function incrementRegular() public {
        counter += 1;
    }

    // Unchecked increment (less gas)
    function incrementUnchecked() public {
        unchecked {
            counter += 1;
        }
    }
}

Storage Packing

Efficiently packing variables can optimize storage usage and reduce costs. Here's an example:

contract StoragePacking {
    // Inefficient storage layout
    struct UserDataInefficient {
        uint256 balance;    // 32 bytes
        uint256 lastLogin;  // 32 bytes
        bool isActive;      // 1 byte but takes full slot
    }

    // Efficient storage layout
    struct UserDataEfficient {
        uint128 balance;    // 16 bytes
        uint64 lastLogin;   // 8 bytes
        bool isActive;      // 1 byte
        // All fit in single 32-byte slot
    }
}

Conclusion

Improving gas efficiency in smart contracts is key to building cost-effective and high-performing blockchain applications. By following these 10 gas optimization tips, developers can cut down transaction costs and boost the efficiency of their smart contracts.

Take this example: My Web3 Startup helped Assassin's Creed resolve months of bugs in just five days to create "Smart Collectibles", a million-dollar digital twin NFT platform. This achievement highlights the impact of well-optimized smart contracts.

I can't recommend them enough. The Smart Contract worked perfectly, the pricing was excellent, and their video guide on contract deployment was clear and helpful.

Such examples emphasize the value of expert reviews and ongoing monitoring. Working with experienced smart contract auditors can help identify areas for improvement while ensuring security and functionality remain intact.

Gas optimization isn’t a one-time fix - it’s a continuous process. Regular security tests and performance checks are essential. For instance, My Web3 Startup helped AIPEPE launch a meme token that hit a $100 million market cap.

With over 127 Web3 projects successfully launched, My Web3 Startup specializes in smart contract reviews and optimization to help developers achieve their goals.

Related Blog Posts

Ready to build and launch a successful Web3 startup?

Send us a message today or give us a call, and let's get started!

Message us on Telegram

Ready to build and launch a successful Web3 startup?

Send us a message today or give us a call, and let's get started!

Message us

Ready to build and launch
a successful Web3 startup?

Send us a message today or give us a call, and let's get started!

Message us on Telegram

© 2025 – MyWeb3Startup