Gas Optimization Patterns for Base L2 Smart Contracts
Learn advanced techniques to minimize gas costs in your Base L2 smart contracts and save your users money.
While Base L2 offers significantly lower gas costs than Ethereum mainnet, optimizing your smart contracts is still crucial for providing the best user experience. In this deep dive, we'll explore advanced gas optimization patterns.
Why Optimize on L2?
Even though Base L2 transactions cost a fraction of mainnet:
- User experience matters - Every penny saved improves adoption
- High-frequency operations - Costs add up for active dApps
- Competitive advantage - Lower costs attract more users
Base L2 Gas Costs
On Base, you're typically paying 1-5% of what you'd pay on Ethereum mainnet. But with proper optimization, you can reduce that even further.
Pattern 1: Storage Optimization
Storage is expensive. Here's how to minimize it:
Use Smaller Data Types
// ❌ Wasteful - each variable uses a full slot (32 bytes)
contract Wasteful {
uint256 a;
uint256 b;
uint256 c;
}
// ✅ Optimized - packed into a single slot
contract Optimized {
uint128 a;
uint64 b;
uint64 c;
}Pack Structs Carefully
// ❌ Not packed - uses 3 storage slots
struct User {
uint256 id; // slot 1
address wallet; // slot 2
bool active; // slot 3
}
// ✅ Packed - uses only 2 storage slots
struct User {
uint256 id; // slot 1
address wallet; // slot 2 (20 bytes)
bool active; // slot 2 (1 byte) - packed!
}Packing Rule
Solidity packs variables into 32-byte slots from right to left. Order your struct fields from largest to smallest for optimal packing.
Pattern 2: Calldata vs Memory
Understanding the difference can save significant gas:
// ❌ Expensive - copies to memory
function processArray(uint256[] memory data) external {
for (uint256 i = 0; i < data.length; i++) {
// Process data
}
}
// ✅ Cheaper - reads directly from calldata
function processArray(uint256[] calldata data) external {
for (uint256 i = 0; i < data.length; i++) {
// Process data
}
}Gas saved: ~200 gas per array element
Pattern 3: Unchecked Arithmetic
In Solidity 0.8+, arithmetic operations check for overflow by default. When you're certain overflow won't occur, use unchecked:
// ❌ Default - includes overflow checks
function sum(uint256[] memory numbers) public pure returns (uint256) {
uint256 total = 0;
for (uint256 i = 0; i < numbers.length; i++) {
total += numbers[i];
}
return total;
}
// ✅ Unchecked - skips overflow checks when safe
function sum(uint256[] memory numbers) public pure returns (uint256) {
uint256 total = 0;
for (uint256 i = 0; i < numbers.length; ) {
total += numbers[i];
unchecked { ++i; }
}
return total;
}Use Carefully
Only use unchecked when you're absolutely certain overflow cannot occur. Bugs from unchecked arithmetic can be catastrophic.
Pattern 4: Batch Operations
Combine multiple operations into single transactions:
contract TokenDistributor {
// ❌ Multiple transactions required
function distribute(address to, uint256 amount) external {
token.transfer(to, amount);
}
// ✅ Single transaction for multiple recipients
function batchDistribute(
address[] calldata recipients,
uint256[] calldata amounts
) external {
require(recipients.length == amounts.length, "Length mismatch");
for (uint256 i = 0; i < recipients.length; ) {
token.transfer(recipients[i], amounts[i]);
unchecked { ++i; }
}
}
}Gas saved: 21,000 gas per transaction (base transaction cost)
Pattern 5: Events vs Storage
For data that doesn't need to be read on-chain, use events instead of storage:
// ❌ Expensive storage
mapping(uint256 => string) public messages;
function sendMessage(uint256 id, string memory message) external {
messages[id] = message; // ~20,000 gas
}
// ✅ Cheaper event emission
event MessageSent(uint256 indexed id, string message);
function sendMessage(uint256 id, string memory message) external {
emit MessageSent(id, message); // ~2,000 gas
}Gas saved: ~18,000 gas per message
Event Best Practice
Use events for historical data that only needs to be accessed off-chain. Use storage for data that contracts need to read.
Pattern 6: Short-Circuit Evaluation
Order conditions strategically:
// ❌ Always checks expensive condition
function validate(address user) external view returns (bool) {
return expensiveCheck(user) && cheapCheck(user);
}
// ✅ Checks cheap condition first
function validate(address user) external view returns (bool) {
return cheapCheck(user) && expensiveCheck(user);
}If cheapCheck() fails, expensiveCheck() never runs!
Pattern 7: Minimize SLOAD Operations
Each storage read (SLOAD) costs gas. Cache storage variables:
contract Counter {
uint256 public count;
// ❌ Multiple SLOADs
function increment() external {
count = count + 1; // SLOAD
require(count < 100, "Max reached"); // SLOAD
emit Incremented(count); // SLOAD
}
// ✅ Single SLOAD
function increment() external {
uint256 newCount = count + 1; // SLOAD once
require(newCount < 100, "Max reached");
count = newCount; // SSTORE
emit Incremented(newCount);
}
}Real-World Example: Optimized NFT Minting
Let's apply these patterns to an NFT contract:
contract OptimizedNFT {
// ✅ Packed storage
struct TokenData {
address owner; // 20 bytes
uint64 mintTime; // 8 bytes
uint32 tokenId; // 4 bytes
} // Total: 32 bytes (1 slot!)
mapping(uint256 => TokenData) private _tokens;
uint32 private _totalSupply;
event Minted(address indexed to, uint32 tokenId);
// ✅ Optimized minting
function mint(address to) external returns (uint32) {
uint32 tokenId;
unchecked {
tokenId = ++_totalSupply;
}
_tokens[tokenId] = TokenData({
owner: to,
mintTime: uint64(block.timestamp),
tokenId: tokenId
});
emit Minted(to, tokenId);
return tokenId;
}
// ✅ Batch minting
function batchMint(
address[] calldata recipients
) external returns (uint32[] memory) {
uint32 startId = _totalSupply;
uint32[] memory tokenIds = new uint32[](recipients.length);
for (uint256 i = 0; i < recipients.length; ) {
uint32 tokenId;
unchecked {
tokenId = ++_totalSupply;
tokenIds[i] = tokenId;
}
_tokens[tokenId] = TokenData({
owner: recipients[i],
mintTime: uint64(block.timestamp),
tokenId: tokenId
});
emit Minted(recipients[i], tokenId);
unchecked { ++i; }
}
return tokenIds;
}
}Measuring Gas Savings
Always measure your optimizations! Use Hardhat's gas reporter:
import "hardhat-gas-reporter";
export default {
gasReporter: {
enabled: true,
currency: 'USD',
coinmarketcap: process.env.CMC_API_KEY,
},
};Run your tests:
npx hardhat testTools and Resources
- Hardhat Gas Reporter - Track gas usage in tests
- Tenderly - Debug and optimize transactions
- Etherscan Gas Tracker - Monitor network gas prices
- OpenZeppelin Contracts - Study optimized implementations
Key Takeaways
- Storage is expensive - Pack variables, use events when possible
- Batch operations - Combine multiple actions into one transaction
- Use
calldata- For read-only external function parameters - Cache storage reads - Minimize SLOAD operations
- Unchecked arithmetic - When overflow is impossible
- Measure everything - Use gas reporters to track improvements
Keep Learning
Gas optimization is an ongoing process. Stay updated with the latest Solidity features and patterns.
Next Steps
Happy optimizing! ⚡
Related Posts

Base L2 Security Best Practices: Protect Your Smart Contracts
Essential security practices for building safe and secure applications on Base L2, covering common vulnerabilities and how to prevent them.

Building an NFT Marketplace on Base L2
A complete guide to building a gas-efficient NFT marketplace using Base L2, featuring smart contracts, frontend integration, and best practices.