DEV Community

metadevdigital
metadevdigital

Posted on

Solidity Gas Optimization: 10 Techniques to Reduce Transaction Costs

Solidity Gas Optimization: 10 Techniques to Reduce Transaction Costs

cover

When I first deployed a liquidity pool contract to mainnet, my swap function cost 180k gas. A colleague casually mentioned they'd seen similar logic run for 85k on another protocol, and I felt the panic set in — was I just burning users' money for no reason?

Turns out, I wasn't optimizing storage access patterns, and my calldata was structured like a toddler's LEGO creation. Tbh, once I started actually measuring and thinking about what the EVM does under the hood, the improvements were almost embarrassing in hindsight.

Let's talk about the ten techniques that actually move the needle.

1. Pack Your Storage Variables

The EVM stores data in 32-byte slots. If you declare uint256 (32 bytes) next to uint128 (16 bytes), you're wasting a slot.

// BAD: 3 slots
uint256 price;
uint128 amount;
bool isActive;

// GOOD: 1 slot
uint128 amount;
uint128 price;
bool isActive;
Enter fullscreen mode Exit fullscreen mode

That's a 2100 gas difference per write (SSTORE). On a swap function that writes once, not huge. On an AMM that's hit 10k times a day? You're talking real savings.

2. Use Immutable and Constant for Fixed Values

constant values aren't stored in storage — they're inlined at compile time. immutable is set once in the constructor, then hardcoded during deployment.

// BAD: storage read every time (100 gas)
address owner;

// GOOD: hardcoded at deployment (0 gas on read)
address immutable owner;

constructor(address _owner) {
  owner = _owner;
}
Enter fullscreen mode Exit fullscreen mode

Uniswap V3 uses this everywhere. Their factory address, their fee tiers — all hardcoded. No storage lookups.

3. Minimize Storage Reads and Writes

Every SSTORE costs 20k gas the first time (if new), 5k if updating. SLOAD is 2.1k for warm slots, 100 cold. Cache values in memory.

// BAD: reads from storage 3 times
function updateAndCheck() external {
  require(balances[msg.sender] > 0);
  balances[msg.sender] -= 100;
  require(balances[msg.sender] < 1000);
}

// GOOD: reads once, uses memory
function updateAndCheck() external {
  uint256 balance = balances[msg.sender];
  require(balance > 0);
  balance -= 100;
  require(balance < 1000);
  balances[msg.sender] = balance;
}
Enter fullscreen mode Exit fullscreen mode

The second version saves ~4k gas. (I wrote the first version without thinking. Don't be like first-me.)

4. Use Bytes32 Instead of String for Fixed Data

Strings are expensive. If you're storing a fixed-length identifier or symbol, use bytes32.

// BAD: dynamic storage, expensive hashing
string public name = "MyToken";

// GOOD: static, hashable, cheaply comparable
bytes32 public constant NAME = "MyToken";
Enter fullscreen mode Exit fullscreen mode

OpenZeppelin went back and forth on this before settling on strings for ERC20 compatibility, but internal data uses bytes32.

5. Avoid Unnecessary External Calls

Every external call is CALL opcode (base 700 gas) plus calldata encoding. Batch them when possible. Curve Finance uses internal balance accounting instead of calling transfer() repeatedly.

// BAD: 3 external calls in a loop
for (uint i = 0; i < users.length; i++) {
  token.transfer(users[i], amounts[i]);
}

// GOOD: batch in the caller
token.transferBatch(users, amounts);
Enter fullscreen mode Exit fullscreen mode

6. Use uint256 Over Smaller Integers

The EVM word size is 256 bits. Operations on uint128 or uint64 often require masking operations, which add gas.

// BAD: 128-bit operations cost extra
uint128 x = 100;
uint128 y = 200;
uint128 z = x + y;

// GOOD: native word size
uint256 x = 100;
uint256 y = 200;
uint256 z = x + y;
Enter fullscreen mode Exit fullscreen mode

Use smaller types only if you're actually packing them in storage. Otherwise, let the EVM breathe.

7. Use Unchecked Math When Safe

Arithmetic overflow/underflow is checked by default in Solidity 0.8+. If you're certain there's no overflow (like a counter in a bounded loop), drop the safety rails.

// BAD: 500 gas per iteration for overflow check
for (uint i = 0; i < 100; i++) {
  sum += values[i];
}

// GOOD: saves ~50k on large loops
for (uint i = 0; i < 100; i++) {
  unchecked { sum += values[i]; }
}
Enter fullscreen mode Exit fullscreen mode

OpenZeppelin's ReentrancyGuard uses unchecked for the nonce counter.

8. Use Bit Shifting Instead of Exponentiation

2**n requires computation. 1 << n is a single opcode (SHL).

// BAD: exponentiation
uint256 scaled = amount * (10 ** 18);

// GOOD: bit shift (when possible)
uint256 shifted = amount << 4; // multiply by 16
Enter fullscreen mode Exit fullscreen mode

Only works for powers of 2. Compound uses this for fee calculations.

9. Check Before You Change

Put require checks before state-changing operations so you don't pay for reverted storage changes.

// BAD: state change before check
function withdraw(uint amount) external {
  balances[msg.sender] -= amount;
  require(balances[msg.sender] >= 0);
}

// GOOD: check before change
function withdraw(uint amount) external {
  require(balances[msg.sender] >= amount);
  balances[msg.sender] -= amount;
}
Enter fullscreen mode Exit fullscreen mode

If the check fails, the first version still charges for the storage write. (That's a real gas leak people miss.)

10. Use Events Instead of Storage for Logging

Events are logged, not stored. They're way cheaper than writing to state.

// BAD: ~20k gas per update
struct History { uint amount; uint timestamp; }
History[] history;
function log(uint amount) {
  history.push(History(amount, block.timestamp));
}

// GOOD: ~375 gas
event LogAmount(uint indexed amount, uint timestamp);
function log(uint amount) {
  emit LogAmount(amount, block.timestamp);
}
Enter fullscreen mode Exit fullscreen mode

That's literally what events exist for — queryable logs that don't bloat storage.

Common Gotchas When Optimizing

Premature optimization will make your code unreadable. Use tools like Hardhat's gas reporter and profile first. Don't guess.

Packing storage across inheritance breaks. If a parent contract adds fields, your child's offsets shift. Uniswap V3 had to be careful here. Document your packing strategy.

Memory isn't always cheaper. Copying large arrays to memory costs more than reading from storage once. Know the tradeoff.

Solidity compiler versions matter — 0.8.20+ uses the Shanghai EVM with cheaper PUSH0. Your optimizations might not be portable to chains still on older opcodes.

Calldata is cheaper to read than memory. If you're accepting uint256[] externally, read it directly from calldata instead of copying to memory.


Here's what I realized after that initial panic: gas optimization isn't black magic. It's just understanding what operations cost and structuring your code to minimize expensive ones. The EVM is deterministic — you pay for what you compute, and you can measure it. My 180k swap function? Down to 92k with these techniques. Users noticed. Transactions confirmed faster. TVL increased.

That's the real win.

Top comments (0)