Web3 Development · 5 min read ·

Gas Optimization for EVM Contracts: What Actually Matters

Practical gas optimization techniques for Solidity and EVM contracts, from storage layout and calldata to packing, caching, and testing tradeoffs.

Gas Optimization Techniques for EVM Contracts

Gas is the real “cloud bill” of Web3. If you ship a contract that’s 20–40% more expensive than it needs to be, you’re effectively taxing every user interaction forever. The good news: most meaningful savings come from a handful of repeatable patterns—storage discipline, calldata usage, and avoiding unnecessary work.

This post focuses on techniques that reliably move the needle without turning your codebase into an unreadable puzzle.

1) Start with measurement (or you’ll optimize the wrong thing)

Before changing code, identify which functions and op patterns burn gas.

  • Foundry: forge test --gas-report gives per-function gas.
  • Hardhat: hardhat-gas-reporter for CI comparisons.
  • On-chain reality check: Compare estimated gas vs. real receipts for typical user paths.

Optimization is contextual: saving 5,000 gas on a function called once per deployment is pointless; saving 2,000 gas on a hot path (swap, mint, claim) is real money.

2) Storage is expensive: reduce SSTORE/SLOAD and structure state

Prefer memory/calldata over storage

Accessing storage (SLOAD) is far more expensive than reading memory or calldata. Common pattern:

  • Cache storage reads in local variables when reused.
  • Write once: accumulate values in memory, then SSTORE at the end.

Example: cache a mapping read:

  • Bad: repeatedly read balances[msg.sender].
  • Better: uint256 bal = balances[msg.sender]; then operate on bal, then write back once.

Pack variables to fit into fewer storage slots

Solidity packs smaller types into a single 32-byte slot when declared adjacently. If you use uint256 everywhere, you waste storage.

  • Good packing candidates: uint128, uint64, uint32, bool (careful), small enums.
  • Keep packed fields next to each other.

Example: Instead of uint256 amount; uint256 timestamp; bool claimed;, consider uint128 amount; uint64 timestamp; bool claimed; (and order them for packing).

Be cautious with bool

bool looks small but can create extra reads/writes depending on packing and how you update it. If a flag sits alone in a slot, it’s wasteful; if it packs cleanly with other fields, it can be fine.

Avoid dynamic storage writes

  • Growing arrays (push) and storing long strings/bytes are expensive.
  • Consider emitting events for data you don’t need on-chain.
  • Use bytes32 identifiers instead of strings.

3) Calldata is your friend: design external functions for cheap inputs

For external functions, prefer calldata over memory for read-only parameters:

  • function foo(bytes calldata data) external is cheaper than bytes memory data because calldata avoids copying.
  • Same for arrays: uint256[] calldata amounts.

Also: if you accept signatures, permit payloads, or proofs, treat them as calldata and decode minimally.

4) Use custom errors (and keep revert data small)

Revert strings are expensive: they add deployment bytecode and increase runtime costs when reverting.

  • Prefer custom errors: error Unauthorized();
  • Revert with revert Unauthorized();

This reduces bytecode size and is now standard for production Solidity.

5) Minimize external calls and repeated work

External calls are not just risky; they’re expensive.

  • Batch operations: offer claimMany() or mintBatch() when possible.
  • Avoid redundant calls: cache addresses like IERC20 token = IERC20(_token); if reused.
  • Short-circuit early: validate cheap conditions before expensive ones.

A practical example from DeFi routers: validate array lengths and deadlines first (cheap), then do transfers/swaps.

6) Loop optimizations that actually help

Loops are common gas sinks.

  • Use unchecked { ++i; } in for loops when overflow is impossible (typical for index increments).
  • Cache array length: uint256 len = arr.length; then loop to len.
  • Avoid storage in loops: copy storage arrays to memory only if it’s cheaper for repeated reads (depends on size).

Be honest: if users can pass unbounded arrays, you’re also creating DoS risk via block gas limits. Put reasonable caps.

7) Choose the right data structures

Mappings are usually cheaper than arrays for lookups.

  • If you need membership checks: mapping(address => bool) beats scanning an array.
  • If you need enumeration: arrays help, but consider whether enumeration belongs off-chain via events.

If you must enumerate on-chain, maintain both:

  • mapping(key => index) plus an array of keys (classic “index mapping” pattern), but note the extra writes.

8) Immutable and constant: cheap reads, smaller state

  • constant values are embedded in bytecode.
  • immutable values are set in the constructor and then read cheaply.

Use these for addresses like routers, token addresses, or domain separators that don’t change. Avoid storing such values in regular state unless you need upgradeability.

9) Optimize hashing and encoding patterns

Hashing is common in permits, Merkle claims, and EIP-712.

  • Prefer keccak256(abi.encodePacked(...)) only when safe from collisions; otherwise use abi.encode.
  • Don’t build large intermediate strings/bytes.
  • When verifying Merkle proofs, use efficient libraries and keep proof lengths minimal.

Example: Airdrop claims can be significantly cheaper by hashing leaf nodes as keccak256(bytes.concat(keccak256(abi.encode(account, amount)))) vs. heavier encodings—though correctness beats micro-savings.

10) Inline assembly: use sparingly, but it can pay

Assembly can reduce overhead for:

  • tight loops
  • custom encoding/decoding
  • minimal proxy patterns

But assembly also increases audit surface area. If the savings are <1–2% on a non-hot path, it’s usually not worth it.

A balanced approach: rely on battle-tested libraries (e.g., Solady, OpenZeppelin where appropriate) and confine assembly to small, well-tested modules.

11) Bytecode size matters (deployment + sometimes runtime)

Large bytecode increases deployment gas and can push you near the 24KB contract size limit.

Tactics:

  • Prefer custom errors over strings (again).
  • Reduce unused public getters.
  • Use libraries carefully: internal libraries get inlined (can grow bytecode); external libraries add call overhead.
  • Split contracts/modules when sensible.

12) Architectural choices: when “optimal” is a different design

The biggest gas wins often come from changing the flow:

  • Lazy accounting: update global indices, compute user balances on demand (common in staking and lending).
  • Checkpointing: store fewer state changes and reconstruct history from events or sparse checkpoints.
  • Off-chain computation + on-chain verification: Merkle roots, signatures, and ZK proofs (trade complexity for gas).

Example: Many reward distribution systems moved from “store each user’s claimable balance” to “store a global reward-per-token index,” cutting writes dramatically.

Conclusion

Gas optimization isn’t about clever tricks—it’s about disciplined state management and avoiding unnecessary work. If you focus on (1) fewer storage writes, (2) calldata-friendly interfaces, (3) tight loops and batching, and (4) smaller revert/bytecode footprint, you’ll capture most of the savings that matter in production.

The final rule: don’t optimize blindly. Measure, change one thing at a time, and keep code auditable. Cheap contracts are great—but cheap, correct, and maintainable contracts are what win.