Picture an NFT contract where minting one token costs users almost twice what a competitor charges. The logic is fine. The problem is five storage writes where two would do, plus a counter that is read from storage on every pass through a loop. Most gas waste looks like that. It is rarely clever assembly that saves money. It is understanding which operations are expensive and touching them less often.
Storage Is Where the Money Goes
Arithmetic in the EVM costs 3 to 5 gas per operation. Storage is in a different league. Since EIP-2929 (the Berlin upgrade), the first read of a storage slot in a transaction is "cold" and costs 2,100 gas. Later reads of the same slot are "warm" and cost 100. Writing a slot that goes from zero to a nonzero value costs 20,000 gas, plus the 2,100 cold access if it is the first touch, so roughly 22,100. Changing an existing nonzero value costs about 5,000 when cold. Setting a slot back to zero gives a refund of 4,800, capped at one fifth of the gas used by the transaction since EIP-3529.
Put those numbers next to each other and the priorities are obvious. One avoided storage write pays for thousands of additions. So start every optimization pass by listing the SSTORE and SLOAD calls on your hot paths, the functions users call most often.
| Operation | Approximate gas | What it means in practice |
|---|---|---|
| ADD, SUB, comparison | 3 | Basically free. Do not optimize these first. |
| SLOAD, warm | 100 | Cheap, but adds up inside loops |
| SLOAD, cold | 2,100 | First read of each slot in a transaction |
| SSTORE, zero to nonzero | 20,000 + 2,100 cold | New balances, new mapping entries, new array items |
| SSTORE, nonzero to nonzero | about 5,000 cold | Updating a value that already exists |
| LOG with one topic, 32 bytes data | about 1,000 | Events are far cheaper than storing history |
| Calldata byte | 4 zero / 16 nonzero | Matters a lot on rollups, see the L2 section |
Pack Storage Variables
Each storage slot is 32 bytes. The compiler packs consecutive variables into one slot when they fit, so declaration order matters. An address takes 20 bytes, which leaves 12 bytes for a uint96, a uint64 timestamp, or a few bools. A struct with uint256, address, uint256, bool uses four slots. Reorder it to uint256, uint256, address, bool and it uses three. If those fields are read or written together, you save a full cold access every time.
Packing only helps when the packed fields are used together. Writing one small field in a shared slot still costs a full SSTORE, and the compiler adds masking operations. Pick types that match real ranges: a uint64 timestamp lasts billions of years, and a uint96 holds more than the total supply of almost any token with 18 decimals.
Use constant and immutable
Values that never change after deployment should not live in storage. A constant is fixed at compile time. An immutable is set once in the constructor. Both are written into the bytecode, so reading them costs a few gas instead of 2,100. Fee recipients, token addresses, and owner roles that you do not plan to rotate are good candidates. Since Solidity 0.8.21 immutables can be assigned conditionally in the constructor, which removed most of the old excuses for keeping them in storage.
Small Habits That Add Up
- Mark array and string parameters of external functions as calldata instead of memory. Calldata is read in place, while memory forces a copy.
- Cache storage values in a local variable before a loop. Reading a storage array length on every iteration costs at least 100 gas each time. Reading a local copy costs 3.
- Replace revert strings with custom errors, available since Solidity 0.8.4. They shrink the bytecode and the revert data. Since 0.8.26 you can pass a custom error directly to require.
- Use events for history that the contract itself never reads. A log of past transfers or price updates belongs in events that your indexer or The Graph picks up, not in an ever-growing storage array.
- Wrap arithmetic in unchecked blocks only when you have proven it cannot overflow, such as decrementing a balance right after checking it is large enough. Since 0.8.22 the compiler already skips the overflow check on simple loop counters, so the old unchecked { ++i } trick is mostly unnecessary.
- Use transient storage (EIP-1153, available since the Dencun upgrade) for values that only need to live during one transaction, such as reentrancy locks. TSTORE and TLOAD cost 100 gas each and the value disappears when the transaction ends.
Avoid Unbounded Loops
A loop over every holder or every order is cheap when the list has ten entries and fails when it has ten thousand. Once a function needs more gas than the block limit allows, it can never run again, and any funds that depend on it are stuck. Design so that each user pays for their own work. Let users claim rewards individually instead of having the contract push payments to everyone. If you must process a list, process it in batches with a start index and a maximum size.
Measure Before and After
Guessing about gas is a waste of time. The compiler optimizer, via-IR, and storage layout interact in ways that are hard to predict. Measure every change.
- 1Write tests that call the hot functions with realistic inputs, including first-time users who trigger zero to nonzero writes.
- 2Run forge snapshot to record gas per test in a .gas-snapshot file and commit it.
- 3Run forge test --gas-report to see the min, average, and max cost per function. In Hardhat projects, hardhat-gas-reporter gives a similar table.
- 4Make one change, then run forge snapshot --diff to see exactly which tests got cheaper or more expensive.
- 5Add forge snapshot --check to CI so a pull request that makes a hot path more expensive gets noticed in review.
Also try different optimizer runs values. A high value such as 10,000 makes calls cheaper and deployment more expensive. A low value does the opposite. For a contract that will be called millions of times, high runs is usually right.
When Not to Optimize
We have reviewed contracts where someone saved 200 gas per call with inline assembly and introduced a bug that could drain the pool. Saving 200 gas at 1 gwei is a fraction of a cent. An exploit is the whole treasury. Readable code is easier to audit, and audits cost real money per line of confusing code. Keep the checks-effects-interactions order, keep access control explicit, and only reach for assembly when a measured hot path justifies it and the auditor agrees.
Layer 2 networks change the maths too. On rollups such as Arbitrum, Optimism, or Base, execution gas is cheap and a large part of the fee comes from posting transaction data to Ethereum. Since EIP-4844 that data goes into blobs, which brought fees down sharply, but the size of your calldata still matters more there than one extra SLOAD. Shorter function arguments and fewer parameters can save more than storage tricks. Check where your contract will actually run before deciding what to optimize.
Optimize the functions users call every day, measure each change, and stop when the code starts getting harder to audit.
Gas optimization is one of the topics our team covers in the smart contract and Ethereum training we run for developers, with hands-on sessions on storage layout, Foundry gas reports, and reviewing real contracts for waste.
Key takeaways
- Storage writes dominate gas costs. A zero to nonzero SSTORE costs about 22,100 gas with cold access, while arithmetic costs 3.
- Pack variables that are used together, and move fixed values to constant or immutable.
- Use calldata, cached loop variables, custom errors, and events instead of storage for history.
- Avoid loops over lists that can grow without limit, and let users pay for their own work.
- Measure every change with forge snapshot or a gas reporter, and never trade audit clarity for a few hundred gas.


