Web3 Development · 5 min read ·

Gas Optimization for EVM Contracts: Tactics That Matter

Practical, high-impact gas optimization techniques for Solidity/EVM contracts, covering storage, calldata, control flow, and tooling.

Gas Optimization Techniques for EVM Contracts

Gas efficiency isn’t a “nice-to-have” in EVM development—it directly impacts user conversion, protocol competitiveness, and even security assumptions (think: block limits, MEV, and griefing). The best optimizations are rarely exotic assembly tricks; they’re usually about choosing the right data layout, avoiding unnecessary storage writes, and structuring execution so the common path is cheap.

Below are techniques we use in production audits and builds—prioritized by impact and reliability.

1) Treat storage like it’s expensive (because it is)

On EVM chains, the biggest recurring costs come from SSTORE/SLOAD. A single extra storage write in a hot function can dominate your gas profile.

Tactics that consistently pay off:

  • Minimize writes, especially from zero → nonzero. Setting a slot from 0 to a value is notably expensive; updating nonzero → nonzero is cheaper; clearing to zero may trigger refunds (subject to refund rules).
  • Avoid redundant writes. Don’t write a value back to storage if it didn’t change.

Example pattern:

  • Cache storage in memory, compute, then write once.
  • If a function needs multiple reads from the same slot, read once and reuse the local variable.

Be opinionated about “storage for convenience.” If something can be derived from existing state or verified from inputs, consider not storing it.

2) Use calldata efficiently (and don’t copy it by accident)

Calldata is cheaper than memory and vastly cheaper than storage. The classic win: prefer calldata in external functions.

  • Use external functions with calldata parameters for arrays/bytes/strings.
  • Avoid implicit copies: passing calldata arrays into internal functions that expect memory will copy.

A good practice is to mirror signatures:

  • function foo(uint256[] calldata xs) external and then have an internal helper that accepts uint256[] calldata as well.

For frequently-called methods, this alone can shave meaningful gas.

3) Pack storage slots and choose your types deliberately

The EVM stores state in 32-byte slots. If you store multiple small values, you want them to fit in one slot.

Good: pack uint128 + uint64 + uint64 in one slot.

Bad: scattering bool, uint8, uint16 across separate variables without packing considerations (Solidity packs only when variables are in the same storage “group” and order allows it).

Practical tips:

  • Order state variables from largest to smallest within a struct to improve packing.
  • Prefer uint256 when packing isn’t possible anyway—smaller ints can introduce extra masking operations.
  • Use structs for logical grouping and packing control.

This is a real-world lever in DeFi contracts with many per-position parameters.

4) Mappings beat arrays for sparse state; arrays beat mappings for iteration

A common gas mistake is choosing a data structure that fights your access pattern.

  • If you need random access and the set is sparse: mapping(key => value) is typically best.
  • If you need on-chain iteration: arrays are better, but iteration can become unbounded and dangerous.

Opinionated guidance: avoid unbounded loops over dynamic arrays in core functions. If you need enumeration, consider:

  • off-chain indexing (The Graph / custom indexer), or
  • “paged” iteration with explicit bounds.

5) Reduce event payload size (but don’t cheap out on observability)

Events are cheaper than storage for logging, but they’re not free—especially with many indexed topics.

  • Index only what you genuinely filter on.
  • Emit fewer, more meaningful events instead of multiple redundant ones.
  • Avoid logging large dynamic data unless necessary.

A pragmatic approach in protocols: keep events stable and index-friendly, but don’t “optimize away” critical audit trails. Saving 5k gas isn’t worth losing debuggability.

6) Use custom errors instead of revert strings

Custom errors (Solidity error) are cheaper than revert strings and improve ABI-level clarity.

  • require(x, "message") costs more due to string data.
  • Prefer if (!x) revert MyError();

This is an easy win across codebases with many checks.

7) Favor unchecked math where it’s provably safe

Solidity 0.8+ inserts overflow checks by default. In tight loops or counter increments, unchecked can reduce overhead.

Use it only when you have a hard bound:

  • Loop counters with i++ where i < len.
  • Arithmetic on values already constrained.

Don’t cargo-cult unchecked. If it weakens invariants, it’s not an optimization—it’s a bug.

8) Control flow: make the common path cheap

Gas optimization is often about branch prediction by design:

  • Put the most likely branch first.
  • Revert early before expensive work.
  • Avoid complex modifiers stacked on hot functions; sometimes an internal precheck is cheaper.

Also consider:

  • Short-circuit boolean logic (e.g., a && b) to avoid evaluating expensive conditions unnecessarily.

9) External calls are expensive—batch and cache where possible

External calls cost gas and introduce risk (reentrancy, unexpected reverts). From a gas perspective:

  • Batch operations (e.g., multicall patterns) to amortize base transaction costs.
  • Cache values from external contracts when safe and not stale-sensitive.

Example: protocols that repeatedly query an oracle in multiple functions may benefit from fetching once per transaction and passing the price through (or using a cached value with explicit update semantics).

10) Libraries, immutables, and constants

  • constant values are embedded in bytecode and are cheap to read.
  • immutable values are set at deployment and read cheaply thereafter.

Practical uses:

  • Store addresses like WETH, USDC, or a trusted router as immutable.
  • Keep protocol parameters that won’t change (or are governance-fixed) as constant/immutable.

This reduces storage reads and simplifies reasoning.

11) Be careful with proxy patterns and upgradeability overhead

Upgradeability adds runtime overhead:

  • A proxy adds an extra DELEGATECALL and storage reads for implementation slots.
  • Initializers replace constructors and can complicate packing.

If the contract is called constantly (e.g., an AMM pool), you should treat proxy overhead as part of your cost model. Sometimes the correct answer is:

  • minimize the proxy layer on hot paths, or
  • use immutable args / minimal proxies where applicable.

12) Measure, don’t guess: tooling and workflow

A serious gas optimization loop looks like:

  • Write a baseline benchmark (Foundry tests with gas snapshots).
  • Run forge snapshot or equivalent; compare deltas.
  • Use Solidity compiler optimizer settings intentionally (and record them): --optimize --optimizer-runs.
  • Profile with call traces to find real hotspots.

Opinionated note: micro-optimizations that save <200 gas in a cold path are rarely worth readability loss. Optimize the top 5% of functions that drive 95% of spend.

Conclusion: optimize the model, not just the opcode

The most reliable gas reductions come from state design: fewer writes, fewer reads, packed storage, and calldata-first APIs. After that, focus on control flow, revert strategy (custom errors), and avoiding unnecessary external calls. Finally, verify with tooling—because EVM gas costs are full of unintuitive tradeoffs, and “looks cheaper” isn’t evidence.

If you’re building a consumer-facing dapp or a high-frequency DeFi protocol, treat gas as a product feature. Users notice it instantly, and competitors exploit it ruthlessly.