Understanding Gas Optimization in Ethereum Smart Contracts
Introduction
Gas is the lifeblood of Ethereum. Every operation you perform on the blockchain costs gas, and users must pay for it. If your smart contracts aren't optimized, you're essentially wasting your users' money and making your dApp less competitive.
In this comprehensive guide, I'll walk you through the most important gas optimization techniques that can reduce your contract's execution costs by up to 70%.
What is Gas? π’οΈ
Gas is a unit that measures the computational effort required to execute operations on Ethereum. Each operation (storage write, computation, function call) consumes a specific amount of gas.
Why does this matter?
- Users pay for gas with ETH
- Higher gas costs = less adoption
- Optimized contracts = better user experience
- Savings compound at scale
1. Use Efficient Data Types
Problem: Storing Data Inefficiently
// β BAD: Uses more storage slots
pragma solidity ^0.8.0;
contract Inefficient {
uint256 userCount; // 32 bytes (1 slot)
uint256 maxUsers; // 32 bytes (1 slot)
bool isActive; // 1 byte (1 slot) - WASTED SPACE!
uint256 balance; // 32 bytes (1 slot)
}
Solution: Pack Your Variables
// β
GOOD: Packs variables into single storage slots
pragma solidity ^0.8.0;
contract Optimized {
uint128 userCount; // 16 bytes
uint128 maxUsers; // 16 bytes
bool isActive; // 1 byte } All fit in ONE
address owner; // 20 bytes } 32-byte slot!
}
Storage Cost Reduction: 4 slots β 1 slot = 75% savings
Key Insight:
Solidity packs variables into 32-byte slots from right to left. Order variables by size (largest first) to minimize wasted space.
// β
BEST: Optimal ordering
pragma solidity ^0.8.0;
contract MostOptimized {
address owner; // 20 bytes
uint96 balance; // 12 bytes
uint32 lastUpdate; // 4 bytes } All in ONE slot
bool isActive; // 1 byte }
uint256 largeNumber; // 32 bytes (own slot)
}
2. Minimize Storage Writes βοΈ
Storage operations are the most expensive operations in Solidity. Reading costs 2,100 gas, but writing costs 20,000 gas initially.
Problem: Multiple Storage Writes
// β BAD: Multiple storage writes in loop
pragma solidity ^0.8.0;
contract BadLoop {
uint256 public totalSupply;
function batchMint(uint256 count) external {
for (uint256 i = 0; i < count; i++) {
totalSupply++; // 20,000 gas per write!
}
}
}
Cost: 100 mints = 2,000,000 gas π±
Solution: Use Memory Variables
// β
GOOD: Use memory, write once
pragma solidity ^0.8.0;
contract GoodLoop {
uint256 public totalSupply;
function batchMint(uint256 count) external {
uint256 _totalSupply = totalSupply; // Read once
_totalSupply += count; // Cheap memory operation
totalSupply = _totalSupply; // Write once
}
}
Cost: 100 mints = ~44,000 gas π
Gas Saved: ~1,956,000 gas (97.8% reduction!)
3. Use Events Instead of Storage
Events are 10-50x cheaper than storing data.
Problem: Storing Historical Data
// β BAD: Storing every transfer in array
pragma solidity ^0.8.0;
contract BadHistory {
struct Transfer {
address from;
address to;
uint256 amount;
uint256 timestamp;
}
Transfer[] public transfers; // Storage costs 20k+ per write
function transfer(address to, uint256 amount) external {
transfers.push(Transfer(msg.sender, to, amount, block.timestamp));
// Very expensive!
}
}
Solution: Emit Events
// β
GOOD: Use events for logging
pragma solidity ^0.8.0;
contract GoodHistory {
event Transfer(
indexed address from,
indexed address to,
uint256 amount,
uint256 timestamp
);
function transfer(address to, uint256 amount) external {
emit Transfer(msg.sender, to, amount, block.timestamp);
// ~375 gas vs 20,000+ for storage
}
}
Gas Cost:
- Event: ~375 gas
- Storage: 20,000+ gas
- Savings: 98% cheaper β¨
4. Optimize Function Visibility
Problem: Unnecessary External Calls
// β BAD: Calling internal functions externally
pragma solidity ^0.8.0;
contract BadVisibility {
uint256 public data;
function updateData(uint256 newValue) public {
_processData(newValue);
}
function _processData(uint256 value) public { // Should be internal!
data = value;
}
}
// Calling it externally costs extra
// contract.updateData(100); // Expensive path
// contract._processData(100); // Even worse - creates new context
Solution: Use Correct Visibility
// β
GOOD: Correct visibility modifiers
pragma solidity ^0.8.0;
contract GoodVisibility {
uint256 public data;
function updateData(uint256 newValue) external {
_processData(newValue); // Cheap internal call
}
function _processData(uint256 value) internal { // Much better!
data = value;
}
}
Tip: Use external instead of public when not calling internally.
5. Use Mapping Instead of Arrays for Lookups
Problem: Array Iteration
// β BAD: O(n) lookup time, expensive
pragma solidity ^0.8.0;
contract BadLookup {
address[] public users;
function isUserRegistered(address user) external view returns (bool) {
for (uint256 i = 0; i < users.length; i++) {
if (users[i] == user) return true;
}
return false;
}
}
Solution: Use Mapping
// β
GOOD: O(1) lookup, instant
pragma solidity ^0.8.0;
contract GoodLookup {
mapping(address => bool) public isRegistered;
function registerUser(address user) external {
isRegistered[user] = true;
}
function checkUser(address user) external view returns (bool) {
return isRegistered[user]; // O(1) instant lookup
}
}
Performance:
- Array: 100 users = 100 storage reads
- Mapping: 100 users = 1 storage read
- Savings: 100x faster β‘
6. Avoid Expensive Operations
Problem: Unnecessary Computations
// β BAD: Expensive operations
pragma solidity ^0.8.0;
contract ExpensiveOps {
function inefficientMath(uint256 a, uint256 b) external pure returns (uint256) {
// Expensive operations
uint256 result = a ** 2 + b ** 2; // Exponentiation is expensive
for (uint256 i = 0; i < 10; i++) {
result = result * 2 / 3; // Multiple expensive ops
}
return result;
}
}
Solution: Optimize Calculations
// β
GOOD: Optimized math
pragma solidity ^0.8.0;
contract EfficientOps {
function efficientMath(uint256 a, uint256 b) external pure returns (uint256) {
// Pre-calculate or use bit shifts
uint256 result = (a * a) + (b * b); // Multiplication cheaper than exponentiation
result = (result * 1024) / 1536; // Bit operations are cheaper
return result;
}
}
Cost Reduction Tactics:
- β Use multiplication instead of exponentiation
- β Use bit shifts instead of division by powers of 2
- β Cache computed values
- β Avoid unnecessary loops
7. Use Immutable and Constant Variables
Problem: Reading State Variables Multiple Times
// β BAD: Reading storage multiple times
pragma solidity ^0.8.0;
contract BadConstants {
address public owner = msg.sender;
uint256 public maxSupply = 1000000;
function checkOwner() external view returns (bool) {
if (msg.sender == owner) { // Storage read 1: 2,100 gas
if (msg.sender == owner) { // Storage read 2: 2,100 gas
return true;
}
}
return false;
}
}
Solution: Use Immutable and Constant
// β
GOOD: Use immutable for write-once values
pragma solidity ^0.8.0;
contract GoodConstants {
address immutable owner;
uint256 constant MAX_SUPPLY = 1000000;
constructor() {
owner = msg.sender;
}
function checkOwner() external view returns (bool) {
if (msg.sender == owner) { // No storage read! Embedded in bytecode
return true;
}
return false;
}
}
Difference:
-
constant: 21 gas (embedded in bytecode) -
immutable: 21 gas (after constructor) -
state variable: 2,100 gas
Savings: 100x cheaper π―
8. Batch Operations
Problem: Multiple Transactions
// β BAD: Multiple function calls
// User calls transfer 10 times = 10 separate transactions
// Each transaction pays for initialization overhead
Solution: Batch in Single Function
// β
GOOD: Batch operation
pragma solidity ^0.8.0;
interface IERC20 {
function transfer(address to, uint256 amount) external returns (bool);
}
contract BatchTransfer {
IERC20 token;
function batchTransfer(
address[] calldata recipients,
uint256[] calldata amounts
) external {
require(recipients.length == amounts.length, "Mismatch");
for (uint256 i = 0; i < recipients.length; i++) {
token.transfer(recipients[i], amounts[i]);
}
// One transaction, one initialization cost
}
}
Savings: Reduces transaction overhead by 90% for batch operations
9. Use Calldata for Large Data
Problem: Memory Allocation Overhead
// β BAD: Unnecessary memory copies
pragma solidity ^0.8.0;
contract BadMemory {
function processArray(uint256[] memory data) external pure returns (uint256) {
// 'memory' keyword copies calldata to memory (expensive!)
uint256 sum = 0;
for (uint256 i = 0; i < data.length; i++) {
sum += data[i];
}
return sum;
}
}
Solution: Use Calldata When Not Modifying
// β
GOOD: Use calldata for read-only data
pragma solidity ^0.8.0;
contract GoodMemory {
function processArray(uint256[] calldata data) external pure returns (uint256) {
// 'calldata' directly reads from transaction data (cheap!)
uint256 sum = 0;
for (uint256 i = 0; i < data.length; i++) {
sum += data[i];
}
return sum;
}
}
Memory cost: Quadratic (16 gas + 3 gas per word)
Calldata cost: Linear
10. Check Arguments Early (Fail Fast)
Problem: Wasting Gas Before Reverting
// β BAD: Expensive operations before validation
pragma solidity ^0.8.0;
contract BadValidation {
function transfer(address to, uint256 amount) external {
// Expensive operation first
uint256 result = amount * 1000;
// Validation after (gas wasted if this fails!)
require(to != address(0), "Invalid address");
require(amount > 0, "Amount must be > 0");
}
}
Solution: Validate First
// β
GOOD: Validate before expensive operations
pragma solidity ^0.8.0;
contract GoodValidation {
function transfer(address to, uint256 amount) external {
// Validation first (cheap operations)
require(to != address(0), "Invalid address");
require(amount > 0, "Amount must be > 0");
// Expensive operation after (only if valid)
uint256 result = amount * 1000;
}
}
Benefit: Users don't pay for failed operations
Gas Optimization Checklist β
Use this checklist when optimizing your contracts:
- [ ] Pack storage variables - Order by size, use smaller types
- [ ] Minimize storage writes - Use memory for temporary values
- [ ] Use events - Instead of storing historical data
- [ ] Correct visibility -
externaloverpublicfor non-internal calls - [ ] Use mappings - Instead of arrays for lookups
- [ ] Avoid expensive operations - No exponentiation, unnecessary loops
- [ ] Use constants/immutable - For fixed values (100x cheaper)
- [ ] Batch operations - Combine multiple actions
- [ ] Use calldata - For read-only arrays (not memory)
- [ ] Validate early - Check arguments before expensive operations
- [ ] Cache values - Store frequently accessed data in memory
Real-World Example: Token Contract Optimization
Before Optimization
// β UNOPTIMIZED
pragma solidity ^0.8.0;
contract MyToken {
string public name;
string public symbol;
uint256 public totalSupply;
uint256 public decimals;
mapping(address => uint256) public balances;
mapping(address => mapping(address => uint256)) public allowances;
address[] public holders; // Array of all holders
function transfer(address to, uint256 amount) public {
require(amount > 0);
require(balances[msg.sender] >= amount);
balances[msg.sender] = balances[msg.sender] - amount;
balances[to] = balances[to] + amount;
// Store transfer history
transfers.push(Transfer(msg.sender, to, amount));
}
}
After Optimization
// β
OPTIMIZED
pragma solidity ^0.8.0;
contract MyTokenOptimized {
string constant name = "My Token";
string constant symbol = "MTK";
uint8 constant decimals = 18;
uint256 public totalSupply;
mapping(address => uint256) public balances;
mapping(address => mapping(address => uint256)) public allowances;
event Transfer(indexed address from, indexed address to, uint256 amount);
function transfer(address to, uint256 amount) external {
require(amount > 0, "Amount must be > 0");
require(to != address(0), "Invalid address");
require(balances[msg.sender] >= amount, "Insufficient balance");
uint256 senderBalance = balances[msg.sender]; // Cache in memory
senderBalance -= amount;
balances[msg.sender] = senderBalance; // Single write
balances[to] += amount;
emit Transfer(msg.sender, to, amount); // Event instead of storage
}
}
Gas Savings: ~60-70% per transaction π
Tools for Gas Analysis
1. Hardhat Gas Reporter
npm install --save-dev hardhat-gas-reporter
2. Solidity Optimizer
Enable in hardhat.config.js:
solidity: {
version: "0.8.0",
settings: {
optimizer: {
enabled: true,
runs: 200
}
}
}
3. Etherscan Gas Tracker
Check real costs on etherscan.io/gastracker
Key Takeaways π
- Storage is expensive - Use memory for temporary values
- Pack your variables - Fit multiple values into single storage slots
- Use events - For logging and history tracking
- Optimize data structures - Mappings over arrays for lookups
- Cache values - Read from storage once, use in memory
- Constants/Immutable - Use for fixed values (100x cheaper)
- Batch operations - Reduce transaction overhead
- Validate early - Check arguments before expensive operations
Conclusion
Gas optimization isn't just about saving moneyβit's about building better user experiences and making your DApps competitive. Every gas unit you save multiplies across thousands of users.
Start with the low-hanging fruit (storage packing, caching, events), then profile your contracts to find the biggest savings opportunities.
Your users will thank you. π
Resources
- π Solidity Gas Optimization Guide
- π¬ Ethereum Yellow Paper
- π‘ Openzeppelin Best Practices
- π οΈ Hardhat Documentation
What's Your Experience?
Have you optimized gas in your smart contracts? Share your biggest gas-saving wins in the comments below! What techniques have worked best for you? π
Let's build efficient Web3 together! π
Connect with me:
- π¦ Twitter
- πΌ LinkedIn
- π§ Email: info.millihub@gmail.com
- π GitHub
Top comments (0)