Best Practices for Gas Optimization in Solidity

1. Why Gas Optimization Matters

When developing smart contracts on Ethereum, Gas is an unavoidable concept. It is neither merely a “transaction fee” nor just a temporary cost caused by network congestion. Instead, it represents a long-term constraint on contract design quality.

Many developers start paying attention to Gas only after encountering situations such as:

  • Exceptionally high deployment costs
  • Frequent “Out of Gas” errors when users call certain functions
  • Significant cost differences between different implementations of the same functionality

These issues usually do not stem from business logic mistakes, but rather from a lack of intuition about the EVM cost model.

1.1 The Two Meanings of Gas

Before discussing optimization, it is important to distinguish two commonly confused concepts:

  • gas used: the number of Gas units actually consumed when executing a transaction
  • gas price: the price you are willing to pay per unit of Gas

Contract code can only affect gas used, not gas price.

This means:

  • Network congestion increases gas price, but does not change a contract’s gas usage
  • A poorly designed contract is expensive under any network conditions
  • When gas price is high, inefficiencies in contract design become even more costly

Therefore, Gas optimization is not about “betting on network conditions,” but about minimizing the number of Gas units consumed per execution.

1.2 Why “Functionally Correct” Does Not Mean “Cost-Efficient”

In traditional software development, as long as the program works correctly, performance issues can often be optimized later. In smart contracts, however, performance is cost.

Even a contract that:

  • Has no security vulnerabilities
  • Implements the intended functionality correctly
  • Passes all tests

can still become impractical because:

  • Certain functions are too expensive to execute on-chain
  • Transaction failure rates increase during peak periods
  • Users pay unnecessary costs for the same functionality over time

These problems are rarely caused by “wrong code,” but by ignoring structural Gas costs at the design stage.

1.3 The Goals of Gas Optimization

Gas optimization is not about achieving the absolute lowest cost, but about serving three practical goals:

  1. Reducing long-term usage costs For frequently called functions, saving even a few hundred Gas can add up significantly over time.

  2. Improving transaction success rates More predictable Gas usage reduces the risk of running out of Gas in complex execution paths.

  3. Improving cost predictability Making it easier for callers to estimate required Gas and reducing uncertainty.

This is why Gas optimization should usually focus on:

  • High-frequency execution paths
  • Core business logic
  • Functions where users directly pay execution costs

1.4 When Not to Over-Optimize

It is important to recognize that Gas optimization has diminishing returns.

The following are usually not worth doing:

  • Introducing complex and obscure code to save a trivial amount of Gas
  • Applying heavy micro-optimizations to low-frequency or cold paths
  • Sacrificing safety checks or readability for marginal gains

A reasonable principle is:

Write safe, clear, and maintainable code first, then optimize only where it truly matters.

In most cases, understanding and avoiding high-cost structures is more valuable than memorizing isolated tricks.

1.5 What Comes Next

The following sections start from the fundamentals:

  • What the EVM actually charges Gas for
  • Which instructions are expensive and which are almost negligible
  • Why storage access is the core driver of Gas costs

Once you understand these principles, later optimization practices will feel natural rather than rule-based.

2. The Gas Cost Model

Before diving into specific optimization techniques, it is essential to build a clear cost intuition: not all EVM operations cost the same.

Many Solidity constructs that appear “simple” consume large amounts of Gas because they trigger expensive low-level instructions.

The goal of this section is simple: Know which operations should be avoided or minimized, and which can be largely ignored.

2.1 Instruction-Level Pricing

The EVM is a stack-based virtual machine. Solidity code is compiled into a sequence of EVM opcodes such as:

  • Arithmetic and comparison instructions like ADD, SUB, LT
  • Memory operations like MLOAD, MSTORE
  • Storage operations like SLOAD, SSTORE
  • External calls like CALL, DELEGATECALL

Gas is charged entirely at the instruction level, not at the Solidity syntax level. This means:

  • A single line of Solidity can compile into multiple opcodes
  • Different-looking code can compile into very different instruction sequences
  • Gas differences come from instruction types and counts, not from code length

In essence, Gas optimization means one thing:

Execute expensive instructions fewer times.

2.2 Cost Tiers

From a cost perspective, EVM operations can be roughly grouped (from cheapest to most expensive):

  • Pure computation (arithmetic, comparisons, bitwise operations)
  • Memory reads and writes
  • Calldata reads
  • Storage reads (SLOAD)
  • Storage writes (SSTORE)
  • External calls and large data returns

The key takeaway is:

Storage operations are far more expensive than most computations.

This is why so many Gas optimizations ultimately converge on the same goal: reducing storage access.

2.3 What Storage Is and Why It Is Expensive

In Solidity, all state variables are stored in storage. From the EVM’s perspective, storage is a massive key–value store:

  • Key: storage slot index
  • Value: 32 bytes of data

Storage has several defining characteristics:

  • Data is persistent
  • It affects the global state trie
  • All full nodes must agree on its contents

Every storage write modifies the global state, which is the fundamental reason storage operations are so costly.

2.4 The Real Cost of Reading Storage

When you read a state variable, for example:

uint256 x = count;

the key instruction is SLOAD.

Since EIP-2929, SLOAD has two cost modes:

  • Cold access: the first access to a storage slot in a transaction
  • Warm access: subsequent accesses to the same slot in the same transaction

Intuitively:

  • The first read “brings the value in”
  • Subsequent reads are cheaper, but still far more expensive than memory or computation

Even warm SLOAD is significantly more expensive than arithmetic or memory access.

2.5 Why Writing Storage Is Among the Most Expensive Operations

When you modify a state variable, such as:

count = count + 1;

this is not a simple increment, but a full read–modify–write sequence:

  1. SLOAD – read the old value
  2. Perform the addition
  3. SSTORE – write the new value

The cost of SSTORE depends on the state transition:

  • Zero → non-zero: most expensive
  • Non-zero → non-zero: less expensive
  • Non-zero → zero: cheaper and may trigger a refund

These rules are designed to encourage contracts to free unused storage.

2.6 What a Single Line of Solidity Really Does

Consider this common statement:

count += 1;

From Solidity’s perspective, it is trivial. From the EVM’s perspective, it usually means:

  • One SLOAD
  • One arithmetic instruction
  • One SSTORE

If you repeat it in the same function:

count += 1;
count += 1;
count += 1;

you are not performing three additions, but triggering:

  • 3 SLOADs
  • 3 SSTOREs

This is how Gas costs escalate quickly.

2.7 A Key Intuition

At this point, a crucial intuition should be clear:

  • Arithmetic and logic are rarely the bottleneck
  • Storage reads and writes dominate Gas costs
  • Repeated access to the same storage slot is one of the most common and overlooked sources of waste

Once this intuition is established, later optimizations—caching, write merging, storage packing—will feel obvious rather than magical.

3. Reducing Storage Reads and Writes

After understanding the EVM cost model, the priority of Gas optimization becomes very clear:

Any reduction in storage access almost certainly leads to significant Gas savings.

Most Gas optimization techniques revolve around a single core objective:

Make SLOAD and SSTORE execute as few times as possible.

3.1 Why Storage Optimization Has the Highest ROI

As discussed earlier:

  • Arithmetic operations are very cheap
  • Memory operations are moderately priced
  • Storage writes are among the most expensive operations

This implies:

  • Eliminating a single SSTORE is often more valuable than optimizing dozens of arithmetic instructions
  • Optimizing storage access typically yields order-of-magnitude improvements

In practice, Gas optimization should follow this order:

  1. First, reduce storage reads and writes
  2. Then consider secondary optimizations such as loops, parameters, and bit-level tricks

3.2 Caching Repeated Reads: Turning Multiple SLOADs into One

One of the most common and easily overlooked inefficiencies is reading the same state variable multiple times within a function.

For example:

function increment() external {
    require(count < max, "too large");
    count = count + 1;
    emit Updated(count);
}

In this code, count is actually read multiple times:

  • Once in the require
  • Once during the increment
  • Once again when emitting the event

A more efficient version caches it in a local variable:

function increment() external {
    uint256 c = count;
    require(c < max, "too large");
    c = c + 1;
    count = c;
    emit Updated(c);
}

The result is:

  • Storage is read only once
  • Storage is written only once
  • All intermediate operations happen in memory

This change barely affects readability, yet can significantly reduce Gas usage.

3.3 Merging Writes: Avoiding Repeated SSTOREs

Another common issue is writing to the same storage variable multiple times within a single function.

For example:

function update(uint256 x) external {
    value += x;
    if (x > threshold) {
        value += bonus;
    }
}

Although the logic is clear, this code may trigger multiple SSTOREs on value.

A better approach is to perform all calculations in memory first:

function update(uint256 x) external {
    uint256 v = value;
    v += x;
    if (x > threshold) {
        v += bonus;
    }
    value = v;
}

The guiding principle is:

Do not repeatedly write to storage inside conditional logic—compute first, then write back once.

3.4 Storage Pitfalls Inside Loops

Loops are where storage inefficiencies are most easily amplified.

Consider the following implementation:

function sum(uint256[] calldata arr) external {
    for (uint256 i = 0; i < arr.length; i++) {
        total += arr[i];
    }
}

In this loop:

  • total is read and written on every iteration
  • For an array of length n, this triggers n SLOADs and n SSTOREs

A more efficient version is:

function sum(uint256[] calldata arr) external {
    uint256 t = total;
    uint256 len = arr.length;

    for (uint256 i = 0; i < len; i++) {
        t += arr[i];
    }

    total = t;
}

This small change has an impact that grows linearly with the array length.

3.5 Design Principles for Avoiding Storage Writes in Loops

When designing contracts, try to avoid patterns like:

  • Writing to storage inside unbounded loops
  • Updating state on every iteration
  • Letting loop bounds be fully controlled by external input

Better alternatives include:

  • Computing results in memory and committing once
  • Designing batch interfaces with strict per-call limits
  • Moving complex computation off-chain and only verifying results on-chain

These are not mere “coding tricks,” but structural design decisions that should be made early.

3.6 Using Mappings Instead of Arrays

In contracts, the choice between arrays and mappings is not just a style preference—it reflects a fundamental cost model difference:

  • Array operations like search, deduplication, or deletion often require traversal, with O(n) cost and unbounded loops
  • Mapping access is direct by key, close to O(1), making it more predictable and Gas-efficient

Case 1: Membership Checks (contains)

Not recommended: storing members in an array and searching on-chain

address[] public members;

function isMember(address a) public view returns (bool) {
    for (uint256 i = 0; i < members.length; i++) {
        if (members[i] == a) return true;
    }
    return false;
}

Problems:

  • Requires traversal on every check
  • Cost grows with the number of members
  • Worst case can run out of Gas

Recommended: use a mapping for existence checks

mapping(address => bool) public isMember;

function addMember(address a) external {
    isMember[a] = true;
}

function removeMember(address a) external {
    isMember[a] = false;
}

Advantages:

  • O(1) existence checks
  • Predictable cost
  • No loops required

Case 2: Enumerable Sets (O(1) checks + enumeration)

Some applications require both:

  • O(1) membership checks
  • Enumeration of all members (e.g., for frontends)

A common pattern is mapping + array:

mapping(address => bool) public isMember;
address[] public memberList;
mapping(address => uint256) private indexPlusOne; // index + 1, 0 means absent

function addMember(address a) external {
    if (indexPlusOne[a] != 0) return;
    isMember[a] = true;
    memberList.push(a);
    indexPlusOne[a] = memberList.length;
}

function removeMember(address a) external {
    uint256 idxPlusOne = indexPlusOne[a];
    if (idxPlusOne == 0) return;

    uint256 idx = idxPlusOne - 1;
    uint256 last = memberList.length - 1;

    if (idx != last) {
        address lastAddr = memberList[last];
        memberList[idx] = lastAddr;
        indexPlusOne[lastAddr] = idx + 1;
    }

    memberList.pop();
    indexPlusOne[a] = 0;
    isMember[a] = false;
}

Explanation:

  • The mapping handles O(1) checks and indexing
  • The array handles enumeration
  • Deletion uses swap-and-pop to avoid O(n) shifts

Note:

  • This introduces extra storage (index mapping)
  • In exchange, operation cost becomes predictable and bounded—usually well worth it

When Arrays Are the Wrong Choice

If you find yourself doing any of the following with arrays, consider switching to mappings:

  • Membership checks
  • Deduplication
  • Deleting arbitrary elements
  • Preventing duplicate inserts
  • Any traversal driven by unbounded user input

In short:

Arrays are for ordered data and index-based access. Mappings are for lookup, deduplication, and existence checks. When searching or deleting is involved, mappings are usually cheaper and safer.

4. Data Location and Function Interface Design

After reducing unnecessary storage access, the next category of high–value optimizations lies in function interface design, especially:

  • Parameter data locations
  • Function visibility

These choices usually do not affect business logic, but they directly influence:

  • Whether unnecessary data copying occurs
  • Whether extra ABI encoding/decoding is triggered
  • Which execution path the EVM takes

Well-designed interfaces often provide low-risk, high-return Gas optimizations.

4.1 Cost Intuition for Data Locations

In Solidity, reference types (arrays, structs, strings, bytes) must explicitly or implicitly specify a data location:

  • calldata: read-only, located in call data
  • memory: read–write, exists during function execution
  • storage: persistent, stored on-chain

From a cost perspective, a simple intuition applies:

  • Reading calldata: cheap
  • Reading/writing memory: moderate
  • Reading/writing storage: expensive

A fundamental rule follows:

Prefer calldata over memory, and memory over storage whenever possible.

4.2 external Functions and calldata

For functions that are only called externally, the recommended pattern is:

function process(uint256[] calldata data) external {
    // use data
}

Reasons:

  • Parameters of external functions naturally live in calldata
  • No need to copy data into memory
  • For large arrays or complex structs, the savings can be substantial

By contrast:

function process(uint256[] memory data) public {
    // use data
}

Even if data is not modified, the compiler must:

  • Copy the entire calldata payload into memory
  • Pay the additional Gas cost for that copy

4.3 When memory Is Required

The limitation of calldata is clear: it is read-only.

If a function needs to:

  • Modify array contents
  • Sort
  • Deduplicate
  • Dynamically construct new arrays

then memory is required.

Example:

function normalize(uint256[] calldata data)
    external
    returns (uint256[] memory)
{
    uint256[] memory result = new uint256[](data.length);
    for (uint256 i = 0; i < data.length; i++) {
        result[i] = data[i] / 2;
    }
    return result;
}

Key points:

  • Input parameters use calldata to avoid copying
  • Output uses memory because it must be writable

This is a very common and sensible pattern.

4.4 Gas Implications of Function Visibility

Function visibility affects not only accessibility, but also Gas cost.

A useful intuition:

  • external: reads parameters directly from calldata, cheapest
  • public: parameters are copied into memory, more expensive
  • internal: often inlined or called via jump, very cheap
  • private: similar to internal, but limited to the contract

An important conclusion:

public is not a universally optimal “inside-and-outside” choice.

4.5 External Entry + Internal Implementation

A highly recommended pattern in real projects is:

  • Expose external functions
  • Move core logic into internal functions

Example:

function update(uint256 x) external {
    _update(x);
}

function _update(uint256 x) internal {
    // core logic
}

Benefits:

  • External calls use calldata with minimal copying
  • Internal calls are extremely cheap
  • One shared implementation for both internal and external usage

This pattern has almost no downsides and avoids many hidden Gas costs.

4.6 Why You Should Avoid Calling this

A subtle but expensive anti-pattern is:

this.update(x);

Even if update is defined in the same contract, this triggers:

  • A full external call
  • ABI encoding and decoding
  • A CALL opcode execution

Consequences:

  • Higher Gas cost
  • More complex execution path
  • Potential reentrancy risks

If you find yourself using this.foo(), it usually means:

  • Logic is poorly structured
  • Internal abstraction is insufficient

The correct refactor is to extract logic into an internal function and call it directly.

4.7 A Comparison Example

Less efficient approach:

function foo(uint256[] memory data) public {
    // use data
}

function bar(uint256[] memory data) public {
    foo(data);
}

Recommended approach:

function foo(uint256[] calldata data) external {
    _foo(data);
}

function bar(uint256[] calldata data) external {
    _foo(data);
}

function _foo(uint256[] calldata data) internal {
    // use data
}

Why the second approach is better:

  • Avoids repeated parameter copying
  • Centralizes logic
  • Uses cheaper internal calls

5. State Layout Design: Storage Packing

So far, most optimizations have focused on how state variables are accessed. This section addresses a more structural question:

How state variables are laid out in storage.

A significant amount of Gas waste comes not from frequent access, but from inefficient state layout, leading to:

  • More storage slots than necessary
  • Extra SLOAD / SSTORE operations
  • Amplified long-term costs

5.1 Storage Slots and 32-Byte Alignment

From the EVM’s perspective, storage is organized in 32-byte (256-bit) slots.

Solidity places state variables into slots in declaration order:

  • Variables smaller than 32 bytes may share a slot
  • If remaining space is insufficient, a new slot is used
  • Once a slot is full, no more variables are packed into it

This mechanism is known as storage packing.

5.2 A Basic Packing Example

Consider:

uint128 a;
uint128 b;
uint256 c;

Each uint128 uses 16 bytes, so a and b share one slot. c occupies its own slot. Total: 2 slots.

Now change the order:

uint128 a;
uint256 c;
uint128 b;

Result:

  • a uses the first 16 bytes of slot 0
  • c occupies slot 1 entirely
  • b cannot fit in slot 0 and goes to slot 2

Total: 3 slots—one extra slot used purely due to ordering.

5.3 Why Slot Count Directly Affects Gas

Every additional storage slot increases long-term cost:

  • More slots read → more SLOADs
  • More slots written → more SSTOREs
  • Higher cost when copying or updating structs

This is especially important in:

  • High-frequency functions
  • Struct-heavy designs
  • Long-lived contracts

5.4 Smaller Types Are Not Always Cheaper

Important caveat:

  • Using types smaller than 256 bits does not automatically save Gas
  • Savings only occur if packing actually happens

Example:

uint128 a;
uint256 b;

Even though a is smaller, it still occupies its own slot because b follows it.

Type choice and declaration order must be considered together.

5.5 When Storage Packing Is Worth the Effort

Not every contract needs aggressive packing.

Storage packing is most valuable when:

  • There are many state variables
  • Structs are used extensively
  • State is frequently read and written
  • The contract has a long operational lifetime

For small, rarely-used contracts, the benefit may be marginal.

6. Loops and Batch Processing

From the previous sections, one pattern should already be clear:

  • Storage access is expensive
  • Loops amplify that cost linearly

Loops themselves are not inherently bad, but unbounded or poorly designed loops almost always become a Gas problem.

6.1 Why Loops Easily Become Gas Black Holes

From the EVM’s perspective, a loop is nothing special—it simply:

  • Repeats a sequence of instructions
  • Pays the full instruction cost on every iteration

If the loop body contains:

  • Storage reads or writes
  • Expensive computations
  • External calls

then Gas usage grows linearly with the iteration count.

The risk becomes severe when the loop length is controlled by external input.

6.2 Caching Array Length and Intermediate Results

A very common and basic optimization is caching an array’s length.

For example:

function sum(uint256[] calldata arr) external {
    uint256 s = 0;
    for (uint256 i = 0; i < arr.length; i++) {
        s += arr[i];
    }
}

Although arr.length looks trivial, it is re-read on every loop condition check.

A better version is:

function sum(uint256[] calldata arr) external {
    uint256 s = 0;
    uint256 len = arr.length;

    for (uint256 i = 0; i < len; i++) {
        s += arr[i];
    }
}

This optimization saves little Gas per iteration, but the difference becomes noticeable in large arrays or high-frequency calls.

6.3 Avoid Writing to Storage Inside Loops

As emphasized earlier, writing to storage inside loops is extremely expensive.

The principle can be summarized as:

Do calculations inside loops, but commit results to storage only once, outside the loop.

6.4 Using unchecked Safely

Since Solidity 0.8, integer arithmetic includes overflow checks by default. This is valuable for safety, but it also introduces extra Gas cost.

A typical example is a loop counter:

for (uint256 i = 0; i < len; i++) {
    // ...
}

If you can guarantee that:

  • i will never approach type(uint256).max
  • The loop has a strict upper bound

then you can use:

for (uint256 i = 0; i < len; ) {
    // ...
    unchecked {
        i++;
    }
}

The Gas savings are smaller than storage optimizations, but still measurable in large loops.

6.5 Avoid Unbounded Loops

When designing contract interfaces, try to avoid:

  • Loop bounds fully controlled by user input
  • Loops without explicit upper limits

For example:

function process(uint256[] calldata items) external {
    for (uint256 i = 0; i < items.length; i++) {
        // ...
    }
}

If items.length is unrestricted, callers can pass extremely large arrays, causing:

  • Transaction failure due to Out of Gas
  • Practical unavailability of the function

Common improvements include:

  • Enforcing a maximum length
  • Splitting work across multiple transactions
  • Providing paginated or cursor-based APIs

6.6 Trade-offs in Batch Design

Batch processing is often used to reduce transaction count and amortize fixed costs, but it is not free.

Advantages:

  • Fewer external calls
  • Amortized function entry and validation cost

Risks:

  • Unpredictable per-transaction Gas usage
  • Higher likelihood of Out of Gas
  • Harder Gas estimation

Therefore, batch interfaces should usually provide:

  • A clear per-call upper bound
  • Predictable worst-case cost
  • Well-defined failure behavior

6.7 A Batch Processing Example

Not recommended:

function batchUpdate(uint256[] calldata ids) external {
    for (uint256 i = 0; i < ids.length; i++) {
        update(ids[i]);
    }
}

Improved version:

uint256 constant MAX_BATCH = 100;

function batchUpdate(uint256[] calldata ids) external {
    uint256 len = ids.length;
    require(len <= MAX_BATCH, "too many items");

    for (uint256 i = 0; i < len; ) {
        _update(ids[i]);
        unchecked {
            i++;
        }
    }
}

The key improvement is not raw Gas savings, but:

  • Controlled cost
  • Predictable behavior
  • A more user-friendly interface

7. Error Handling and Bytecode Size Optimization

Gas optimization discussions often focus on the “successful execution path,” while overlooking failure paths and contract bytecode size.

In reality, error handling affects not only revert-time Gas usage, but also:

  • Deployment cost
  • Baseline execution cost
  • Bytecode size and maintainability

This section focuses on a practical and consistently beneficial optimization: efficient error handling and reverts.

7.1 Reverts Are Not Free

When a transaction reverts, state changes are rolled back—but Gas is not fully refunded.

Costs that still apply include:

  • Gas consumed by executed instructions
  • Data returned with the revert
  • ABI encoding overhead

Therefore, frequently triggered validation logic deserves careful cost consideration, even on failure paths.

7.2 The Real Cost of require(string)

The traditional error-handling pattern is:

require(msg.sender == owner, "Not owner");

The problem is not correctness, but cost:

  • The string literal is embedded in bytecode
  • Each revert returns the string data
  • Longer strings increase deployment and revert costs

In large contracts, extensive use of require(string) significantly increases bytecode size.

7.3 Motivation for Custom Errors

Solidity 0.8.4 introduced custom errors:

error NotOwner();

Used as:

if (msg.sender != owner) revert NotOwner();

Advantages:

  • No embedded strings
  • Error identifiers encoded as selectors
  • Much smaller revert payloads

From a cost perspective, this reduces:

  • Deployment Gas
  • Revert-path Gas

7.4 Comparison Example

Using require(string):

function withdraw() external {
    require(msg.sender == owner, "Not owner");
    // ...
}

Using a custom error:

error NotOwner();

function withdraw() external {
    if (msg.sender != owner) revert NotOwner();
    // ...
}

Gas usage on the success path is nearly identical, but differences appear in:

  • Contract size
  • Revert Gas cost
  • Error encoding efficiency

These differences accumulate in complex or high-frequency contracts.

7.5 How Detailed Should Error Messages Be?

A common misconception is:

More detailed error messages are always better.

From an on-chain perspective, this is often untrue.

A better division of responsibility is:

  • On-chain: concise, structured error identifiers
  • Off-chain: documentation or mapping tables explaining errors

Custom errors fit this model well because they are:

  • Structured
  • Parameterizable
  • Easy for frontends and SDKs to decode

Example:

error InsufficientBalance(uint256 available, uint256 required);

7.6 Why Bytecode Size Matters

Contract bytecode size directly affects:

  • Deployment cost
  • Deployment success (there is a size limit)
  • Baseline execution cost (larger code costs more to load)

The following increase bytecode size:

  • Large numbers of string literals
  • Repeated logic branches
  • Verbose error messages

Gas optimization is therefore not only about runtime execution, but also deployment-time efficiency.

7.7 Balancing Error Handling and Readability

It is important to stress:

  • Custom errors are not about extreme compression
  • They do not require sacrificing readability

A reasonable approach is:

  • Use custom errors for public-facing core interfaces
  • Use require selectively for internal assertions or development checks
  • Avoid embedding long textual descriptions in bytecode

8. Event and Return Value Design

In smart contracts, events and function return values are commonly used to expose information externally. However, if designed poorly, they can easily become hidden sources of Gas consumption, especially in high-frequency or data-heavy scenarios.

The core idea of this section can be summarized as:

Blockchains are good at state verification, not data querying.

Understanding this helps you make more economical design choices for events and return values.

8.1 The Role Boundary of Events

The primary purposes of events are:

  • Allowing off-chain systems to listen and index
  • Recording important state changes
  • Supporting auditing and analysis

Events are not readable on-chain and do not affect subsequent execution logic. From an execution perspective, events are “write once, off-chain only” data.

This leads to a key design principle:

Events should serve off-chain consumers, not replace on-chain state queries.

8.2 Gas Cost Composition of Events

The Gas cost of an event consists of two parts:

  1. Topics

    • The event signature
    • Up to three indexed parameters
  2. Data

    • Non-indexed parameters
    • Charged by byte size

Intuitively:

  • indexed parameters improve filterability
  • But they are not free
  • Larger data payloads cost more Gas

Event design therefore requires a trade-off between queryability and cost.

8.3 Proper Use of indexed

Consider a typical transfer event:

event Transfer(address indexed from, address indexed to, uint256 amount);

This design makes sense because:

  • from and to are common filter fields
  • amount is rarely used as a filter

But if written as:

event Transfer(
    address indexed from,
    address indexed to,
    uint256 indexed amount
);

Then:

  • Query power barely improves
  • Gas cost increases
  • You quickly hit the three-index limit

A practical rule of thumb:

Only mark fields as indexed if they are frequently used for filtering.

8.4 Avoid Carrying Large Data in Events

A common but expensive pattern is emitting large data structures:

event DataUpdated(uint256[] values);

Problems:

  • Entire arrays are written to logs
  • Gas cost grows linearly with data size
  • Both on-chain execution and off-chain storage become expensive

Better alternatives include:

  • Emitting only identifiers, hashes, or counters
  • Storing full data off-chain
  • Linking via hashes or IDs

Example:

event DataUpdated(bytes32 dataHash);

8.5 Avoid Returning Large Arrays On-Chain

Solidity allows functions to return arrays or structs:

function getUsers() external view returns (User[] memory);

While intuitive, this design is not cheap.

What Happens in On-Chain Calls

If this function is called by another contract, even as a view function, it still consumes Gas. The EVM must:

  • Read all User entries from storage
  • Copy them into memory
  • ABI-encode the entire array
  • Return the encoded bytes

All of these costs scale linearly with array size—with no upper bound.

Why This Is Often Unnecessary

In real-world projects, full data lists are usually:

  • Needed by frontends or backend services
  • Used for display, analytics, or reporting
  • Not relied upon by other contracts

Off-chain systems can retrieve such data for free using eth_call.

This creates a common inefficiency:

  • On-chain callers pay Gas for data
  • The actual consumers are off-chain
  • Off-chain consumers could have read the data at zero cost

A Better Design Approach

Function return values should distinguish between:

  • On-chain callable functions Keep return values minimal or return nothing

  • Off-chain query functions Returning arrays or structs is acceptable, but intended for eth_call only

If on-chain access is required, pagination or cursor-based APIs are safer.

8.6 Pagination and Cursor-Based Access

For large datasets, pagination is usually preferable:

function getUsers(uint256 offset, uint256 limit)
    external
    view
    returns (User[] memory)
{
    // return a slice
}

Benefits:

  • Bounded Gas usage for on-chain calls
  • Efficient off-chain iteration
  • Predictable interface behavior

8.7 Event Design Comparison Example

Not recommended:

event OrderCreated(
    address user,
    uint256[] itemIds,
    uint256[] prices
);

Improved version:

event OrderCreated(
    address indexed user,
    bytes32 orderId
);

With off-chain systems:

  • Resolving full order data via orderId
  • Treating events as index signals, not data containers

This design is far more scalable and cost-effective.

9. Compact Representation and Low-Level Optimizations (Use with Caution)

So far, most optimizations discussed share common traits:

  • No major readability loss
  • Controlled risk
  • Stable, predictable gains

This section covers advanced techniques that can save Gas, but also introduce:

  • Reduced readability
  • Higher implementation complexity
  • Increased audit and maintenance costs

The goal here is not to advocate their use, but to answer:

Which low-level optimizations are worth using, and under what conditions.

9.1 Bit Operations and Bitmaps

A common and relatively safe advanced technique is the bitmap.

Suppose you need to track many boolean states:

  • Whether an address completed a step
  • Whether an ID is used
  • A fixed-size set of flags

The straightforward approach:

mapping(uint256 => bool) used;

This is clear, but each bool consumes an entire storage slot.

Bitmap Approach

If the keys are:

  • Sequential
  • Bounded
  • Numerous

you can pack 256 boolean values into one uint256:

uint256 bitmap;

Example:

function isUsed(uint256 index) internal view returns (bool) {
    return (bitmap & (1 << index)) != 0;
}

function setUsed(uint256 index) internal {
    bitmap |= (1 << index);
}

Benefits:

  • One slot stores 256 flags
  • Far fewer storage reads/writes
  • Significant Gas savings in high-frequency scenarios

9.2 When Bitmaps Make Sense

Bitmaps are suitable when:

  • The maximum number of states is known
  • Indices are controlled and not arbitrary user input
  • Logic is stable and unlikely to change

They are unsuitable when:

  • Keys are addresses or hashes
  • State count is unbounded
  • Readability and flexibility are critical

A useful heuristic:

If you need documentation just to explain what each bit means, complexity has already increased significantly.

9.3 Compact Encoding and “Slot-Saving” Thinking

Some projects go further by:

  • Packing multiple fields into a single uint256
  • Using bit shifts and masks manually
  • Re-implementing storage packing logic

Example:

uint256 packed;

Where:

  • Upper 128 bits store balance
  • Lower 128 bits store timestamp

While this can reduce slot count, it also:

  • Requires bit manipulation on every access
  • Increases risk of boundary bugs
  • Makes auditing and debugging harder

Such techniques are justified only when:

  • Data structures are extremely stable
  • Access frequency is very high
  • Slot count is confirmed as the main bottleneck

9.4 About assembly

Solidity allows inline assembly, enabling direct EVM opcode usage:

  • Skipping compiler-generated overhead
  • Achieving lower Gas in extreme cases

But be cautious:

  • No type checks
  • No overflow protection
  • Dramatically reduced readability

In most business contracts, the risk outweighs the benefit.

9.5 When assembly Is Worth Using

Reasonable scenarios include:

  • Extremely hot execution paths
  • Low-level utility functions
  • Logic that is stable and well-tested
  • Teams with audit support and experience

Avoid using assembly for:

  • Core business logic
  • Permission or fund management
  • Marginal Gas savings

A conservative rule:

If Gas is already within a reasonable range without assembly, do not use assembly.

9.6 Evaluating the Real Gains of Advanced Optimizations

Advanced optimizations usually yield:

  • Tens to hundreds of Gas saved per call
  • Meaningful impact only in high-frequency paths

Before using them, you should already have:

  • Completed storage, interface, and loop optimizations
  • Identified real bottlenecks
  • Measured Gas with actual test data

Otherwise, it is easy to fall into “optimization for its own sake.”

10. How to Verify Whether an Optimization Is Effective

After discussing many techniques, a more important question emerges:

How do you know an optimization is actually worth it?

This section emphasizes a data-driven approach rather than intuition.

10.1 Why You Cannot Rely on Intuition

Gas cost does not always correlate with perceived code complexity:

  • Some complex refactors barely change Gas
  • Some small changes save a lot
  • Some optimizations matter only at scale

Without measurement, it is easy to:

  • Miss high-impact improvements
  • Over-optimize low-impact areas

Gas optimization must therefore be measured engineering, not guesswork.

10.2 What Makes an Optimization “Effective”

An optimization should answer three questions:

  1. How much Gas does it save?
  2. How frequently does this code path execute?
  3. How much complexity or risk does it introduce?

Only when savings justify complexity is the optimization worthwhile.

10.3 Basic Benchmarking Approach

The simplest and most reliable method is before/after comparison:

  • Same input
  • Only one implementation change
  • Compare gas used, not transaction fees

Example:

  • Original function A
  • Optimized function A′
  • Measure Gas under identical conditions

10.4 A Conceptual Comparison Example

Original:

function add(uint256[] calldata xs) external {
    for (uint256 i = 0; i < xs.length; i++) {
        total += xs[i];
    }
}

Optimized:

function addOptimized(uint256[] calldata xs) external {
    uint256 t = total;
    uint256 len = xs.length;

    for (uint256 i = 0; i < len; ) {
        t += xs[i];
        unchecked { i++; }
    }

    total = t;
}

The real question is:

  • At xs.length = 10, 100, 1000
  • Do Gas curves diverge meaningfully?

That divergence is what matters.

10.5 Focus on Worst-Case, Not Just Averages

In smart contracts, worst-case behavior often matters more:

  • Insufficient Gas causes failure
  • Users hit edge cases
  • Batch and loop risks concentrate at extremes

Testing should therefore:

  • Use maximum allowed inputs
  • Cover boundary conditions
  • Check proximity to block or function Gas limits

10.6 Tools Matter Less Than Methodology

Teams may use:

  • Hardhat
  • Foundry
  • Truffle
  • Custom scripts

The methodology is always the same:

  • Fix inputs
  • Repeat tests
  • Compare Gas usage
  • Let data drive decisions

10.7 When to Stop Optimizing

A frequently overlooked question is: when is enough enough?

Stop when:

  • Further savings are marginal
  • Code complexity increases noticeably
  • High-frequency and high-cost paths are already optimized
  • Audit and maintenance costs outweigh benefits

Gas optimization is a balance, not an endless pursuit.

11. An Actionable Gas Optimization Checklist

After systematically covering Gas optimization principles, we can now condense them into a practical checklist for daily development and code reviews.

This is not a rigid rulebook, but a priority-driven review order.

11.1 Design-Phase Checks

Before writing code, consider:

  • Is this state truly necessary, or derivable?
  • Will the state be frequently accessed?
  • Are there unbounded loops or batch interfaces?
  • Does the data structure have a clear size limit?
  • Are lookup, deduplication, or deletion required?

Many Gas issues can be avoided at this stage.

11.2 Storage Checks (Highest Priority)

  • Are storage variables read multiple times in one function?
  • Is storage written inside loops?
  • Can caching reduce SLOAD / SSTORE?
  • Are variable and struct field orders optimized for packing?
  • Is unused state deleted?
  • Are mappings used instead of arrays for lookup and deduplication?
  • If enumeration is needed, is mapping + array + index used with swap-and-pop?

This is where optimization effort pays off the most.

11.3 Function Interface and Parameter Checks

  • Are external interfaces marked external?
  • Do external functions use calldata?
  • Are unnecessary public functions avoided?
  • Are this calls avoided?
  • Is the external-entry + internal-implementation pattern used?

These optimizations are low-risk and consistently effective.

11.4 Loop and Batch Checks

  • Are array lengths and intermediate values cached?
  • Is storage avoided inside loops?
  • Are O(n) lookups avoided in loops?
  • Is unchecked used safely where applicable?
  • Are loop bounds explicit?
  • Do batch interfaces limit per-call size?

Loops amplify Gas—always review them from a worst-case perspective.

11.5 Error Handling and Bytecode Size

  • Are custom errors used instead of require(string)?
  • Are error identifiers concise and structured?
  • Are long strings avoided in bytecode?
  • Is contract size close to deployment limits?

These optimizations become more valuable as contracts grow.

11.6 Events and Return Values

  • Do events only log essential information?
  • Are indexed fields used only for frequent filters?
  • Are large arrays or strings avoided in events?
  • Are large on-chain return values avoided?
  • Are pagination or off-chain indexing used instead?

The goal: do not treat the chain as a database.

11.7 Advanced Optimizations (Use Sparingly)

  • Are foundational optimizations already complete?
  • Is the bottleneck clearly identified?
  • Do bitmaps or packing actually reduce slot usage?
  • Is assembly avoided in core business logic?

Advanced optimizations should be data-backed exceptions, not defaults.

11.8 Let Data Drive Final Decisions

Before merging any optimization:

  • Is there clear Gas comparison data?
  • Is the optimized path high-frequency or critical?
  • Is the added complexity testable and auditable?

If the benefit cannot be clearly explained, the optimization is probably unnecessary.