Web3 Development · 5 min read ·

Gas Optimization for EVM Contracts: What Actually Works

Practical gas optimization techniques for Solidity/EVM contracts: storage patterns, calldata, packing, loops, libraries, and deployment tradeoffs.

Gas Optimization Techniques for EVM Contracts

Gas costs aren’t just a UX tax—they’re a product constraint. High gas makes your protocol harder to use, raises liquidation thresholds, kills small-ticket users, and turns “nice-to-have” safety checks into a constant internal debate. The good news: most meaningful savings come from a handful of patterns that are easy to institutionalize in your codebase.

This article focuses on optimization techniques that reliably move the needle on EVM chains (Ethereum L1 and EVM L2s), without resorting to unreadable hacks.

1) Start with measurement, not vibes

Before touching code, get repeatable benchmarks.

  • Use Foundry gas snapshots: forge test --gas-report and forge snapshot to track regressions in CI.
  • Profile the actual hot paths: Usually swap, mint, withdraw, liquidate, claim, or tight loops in accounting.
  • Separate deployment vs runtime costs: Some optimizations reduce bytecode size (deployment gas), others reduce per-call gas.

Opinionated take: if you’re not pinning gas snapshots in CI for core functions, you’re not “optimizing”—you’re guessing.

2) Storage is expensive; minimize SSTORE/SLOAD

On the EVM, storage dominates cost. A single SSTORE can dwarf multiple arithmetic operations.

Cache storage reads

Repeatedly reading the same storage slot costs multiple SLOADs.

  • Read once into memory/local variable, operate, then write back once.
  • Cache mappings’ values when used multiple times.

Avoid unnecessary writes

A write that doesn’t change the value can still cost gas (and complicate refunds).

  • Check if value changes before writing.
  • When zeroing values, consider whether you actually need to clear (sometimes you can use a nonce/versioning pattern instead).

Prefer “pull” over “push” where appropriate

For reward distribution, a global “push to all users” approach forces loops and writes. “Pull” designs store per-user checkpoints and compute owed rewards on demand, reducing storage churn.

3) Pack storage with intent (but don’t overdo it)

Solidity packs values into 32-byte slots when types fit.

  • Use smaller types when they’re truly bounded (e.g., uint96, uint128, uint32).
  • Group packable fields together in structs (Solidity packs sequentially).

Example: a struct like

  • uint128 balance; uint64 lastUpdated; uint64 flags; can fit into one slot instead of three.

Tradeoff: packed fields can cost extra ops when extracting and updating, especially if you update only one packed field frequently (you may incur read-modify-write). Pack fields that tend to be updated together.

4) Calldata and memory: choose the cheaper lane

Use calldata for external function parameters

For external functions, prefer calldata over memory for arrays/bytes/strings:

  • function foo(bytes calldata data) external is cheaper than copying into memory.

Avoid unnecessary memory allocations

  • Don’t build dynamic arrays in memory unless necessary.
  • Prefer streaming computations over constructing intermediate arrays.

Use immutable and constant

  • constant is embedded in bytecode.
  • immutable is set once in the constructor and accessed cheaply.

Typical targets: addresses (router, token, oracle), scalar params (fee bps, precision).

5) Custom errors > revert strings

Revert strings increase bytecode size and runtime costs.

  • Use error Unauthorized(); and revert Unauthorized();
  • You’ll save deployment gas and some runtime gas.

For a protocol with many require statements, this is one of the easiest “free wins.”

6) Keep loops tight (or eliminate them)

Loops scale gas linearly—and can DOS your function if inputs grow.

  • Avoid unbounded loops over user-controlled arrays.
  • Use bounded loops with strict caps.
  • Prefer “cumulative index” patterns (e.g., global reward index) over iterating across users.

If you must loop:

  • Cache array length: uint256 n = arr.length;
  • Use unchecked { ++i; } when safe.
  • Minimize storage reads inside the loop.

7) unchecked arithmetic, but only after invariants

Solidity 0.8+ adds overflow checks. These are good—but sometimes redundant.

Use unchecked blocks where:

  • You’ve proven bounds (e.g., loop increments i < n).
  • Values are constrained by design (e.g., basis points math).

Do not blanket-disable checks in financial logic unless you can demonstrate invariants in tests and audits. Gas savings aren’t worth subtle overflow risk.

8) Use bytes32 keys and avoid heavy hashing patterns

Mappings are efficient, but key choice matters.

  • Prefer fixed-size keys (bytes32, address, uint256) over dynamic keys.
  • If you must key by multiple values, pre-hash once and reuse the composite key.

Example: rather than repeatedly computing keccak256(abi.encodePacked(a,b,c)) in multiple places, compute it once and pass around the bytes32 id.

9) ABI encoding: avoid abi.encodePacked footguns

From a gas perspective, abi.encodePacked can be cheaper, but it’s risky if you’re building hashes with ambiguous concatenation.

  • Use abi.encode for unambiguous hashing.
  • If you use encodePacked, ensure types and boundaries prevent collisions.

Optimization is pointless if it introduces a signature collision or ID ambiguity.

10) External calls: reduce them, batch them, or precompute

Every external call adds overhead and risk.

Techniques:

  • Batch operations: one call that performs multiple actions can be cheaper than repeated calls (also improves UX).
  • Use permit flows (EIP-2612 / Permit2) to reduce approval transactions.
  • Cache external data if safe (e.g., store oracle round data, or use TWAP windows) to avoid repeated reads.

Be careful: caching oracle data can create stale-price risk. Gas savings must not weaken economic security.

11) Libraries, internal functions, and bytecode size tradeoffs

Inlining can reduce call overhead but increase bytecode size. On mainnet, deployment gas matters; on L2, runtime might matter more.

Practical guidance:

  • Hot-path logic: consider inlining or internal functions.
  • Cold-path admin logic: keep readable; gas doesn’t matter much.
  • Watch the 24KB bytecode limit for contracts.

Also consider splitting large systems into modules (diamond/proxy patterns) carefully—delegation adds overhead on every call.

12) Event strategy: log less, log smarter

Events are cheaper than storage for historical data, but they still cost gas.

  • Emit events for necessary indexing/analytics, not everything.
  • Use fewer indexed topics when possible (topics cost more).
  • Avoid emitting large dynamic data blobs unless required.

A good pattern is emitting compact identifiers (like bytes32 positionId) and letting off-chain systems resolve metadata.

Conclusion: optimize the 20% that drives 80% of costs

Effective EVM gas optimization is mostly about three things: minimizing storage writes, keeping hot paths tight, and avoiding accidental copies/overhead. The best teams treat gas like performance engineering: measure, change one variable at a time, lock gains with snapshots, and never trade away correctness for a few thousand gas.

If you’re building production DeFi or high-frequency on-chain systems, build a culture of gas reviews: every new feature should answer “what does this cost per call, and who pays it?” That mindset will save you more than any single micro-optimization.