How I broke my first voting system — a gas disaster
A flagship case study in Solidity anti-patterns: on-chain string storage, array traversals, and hitting the Block Gas Limit.
Let's address the single most common execution vulnerability in early smart contracts:
Writing a simple, logical loop that behaves beautifully in your test environment, but completely locks up under production load.
I was building my first "production" smart contract: a decentralized voting system for a local developer hackathon with a few hundred participants. I wanted everything to be perfectly decentralized, transparent, and completely immutable.
But within minutes of opening the polls, users began swarming our support desk. Etherscan showed a sea of red transaction alerts: Fail [Out of Gas]. Worse of all, voters were still charged real Polygon gas fees for their failed attempts. They were losing real money, their votes weren't registering, and eventually, critical functions became practically unusable due to gas costs—meaning no one could cast a vote or tally the results.
1. The Story: The Sweat in the Corner of the Room
I was sitting in the corner of the hackathon room, sweating profusely, watching our dApp fail in real-time. We had completely bricked the hackathon voting system.
During local testing with Hardhat, I had populated the system with 5 candidates and 3 voters. Everything worked flawlessly. The transactions took milliseconds and cost virtually zero gas.
But when the hackathon started, over 400 developers rushed to vote at the same time. As the voter list grew, the transaction fees climbed. By the time the 200th vote was cast, casting a new vote cost $15 in Polygon gas. By the 350th vote, MetaMask began throwing warning alerts saying the transaction would fail.
When users forced the transactions through anyway, they lost their gas fees and the contract state rolled back. The voting was dead, and we had to tally the remaining votes manually on a whiteboard.
2. The Metaphor: The Delivery Truck with a Fixed Tank
To understand why this disaster happens, let's look at the mechanical limit of gas (restricted to a tight 20% of our lesson volume):
Think of a validator processing your transactions as a delivery truck driver with a fixed fuel tank limit (the Block Gas Limit) and a cargo checklist (the loops):
- Short Route (Local Testing): You ask the driver to deliver 5 packages to candidates. They complete the route instantly, using only 5% of their fuel tank. The operation is safe and cheap.
- Infinite Route (Production Load): You ask the driver to check every single voter in the entire city to verify if they have voted before casting a new vote. As the city grows, the driver must visit every door.
- The Crash: Halfway through the route, the truck's fuel runs completely dry. The driver stops immediately, dumps all the cargo on the road (transaction state reverts), and walks away with your delivery fee anyway.
- The Permanent Block: No matter how much priority fee you offer, a truck cannot carry more fuel than its physical tank size. The route is simply too long.
3. The Visual Loop Limit Diagram
Our frontend represents this transaction path dynamically. Notice how as the voter dynamic array size ($N$) increases, the gas consumption climbs linearly until it hits the hard Block Gas Limit brick wall:
[!TIP] VISUAL TRIGGER FOR FRONTEND: This diagram illustrates the linear complexity bottleneck of dynamic loops. When rendering this in the dApp UI, animate the dynamic growth of $N$. As $N$ exceeds the safety margin, transition the signal path from a green success node to a flashing red revert node representing the block gas threshold.

4. Technical Explanation: The Physics of Block Gas Limits & Mappings
Let's dive into the technical execution mechanics (forming 80% of our content) to understand the physics of gas in Solidity.
1. The Block Gas Limit Brick Wall
Every block on an EVM chain has a Block Gas Limit (currently around 30 million gas on Ethereum mainnet). This is the maximum amount of gas that all transactions included in a single block are allowed to consume combined.
This is a physical consensus constraint. It exists to ensure that blocks remain small enough for standard consumer hardware nodes to process quickly, preventing centralization. If a single transaction tries to consume more than the block gas limit, it is mathematically impossible to include it in a block. The network will reject it instantly.
2. The Linear Cost of Loops (O(N) Complexity)
Let's look at the vulnerable code that triggered our hackathon disaster:
Every time the EVM runs voterList.length inside a loop, it executes an SLOAD (State Load) opcode. Reading from state storage inside a loop is a massive gas drain:
- Reading a cold storage slot for the first time in a transaction costs 2,100 gas.
- Reading it subsequently costs 100 gas.
As voterList grows to 800 addresses, the EVM must perform 800 loop iterations, loading addresses from disk, executing comparisons, and shifting stack pointers. The gas cost scales linearly ($O(N)$) with the number of voters. Once $N$ is large enough, the gas needed for that loop exceeds the Block Gas Limit. The function is locked forever.
Many beginners assume that view functions are always free to execute. While view calls are free when called externally by a user client (because they run locally on an RPC node), they cost full transaction gas when called internally by another smart contract during write execution. Never run heavy array iterations inside any helper function that write operations rely on.
3. The Scale-Safe Fix: Constant Time O(1) Design
To resolve the disaster, we must design contracts where execution costs are independent of the number of users ($O(1)$ constant time). We replace the array loops entirely with constant-time lookup mappings and off-chain indexing:
Why is this scale-safe?
- Zero Loops: The
votefunction executes in exact constant time ($O(1)$) regardless of whether 10 or 10,000,000 people have voted. The gas cost remains completely flat. - Strings Removed: Candidate names are stored in a cheap Web2 Firebase database or indexed in a Subgraph mapping bytes32 IDs to strings.
- Rich Queries Indexed: Total voters and chronological vote feeds are read instantly via The Graph by listening to the
VoteCastevents, saving validators from ever having to do search lookups.
In high-throughput smart contracts, iterating over dynamic arrays inside write operations is a critical vulnerability. An attacker can intentionally register thousands of junk records, inflating the array size until the function hits the block gas limit and can never be executed again (Denial of Service).
The Off-Chain Indexing Blindspot:
Solidity is built for verification, not search. Trying to query and filter historical transactions inside your Solidity contract is a design flaw. Always emit lightweight events (event) and offload complex queries to decentralized subgraphs or synced Web2 read caches.
Analyze the ScalableVoting code above. Identify the gas cost difference between storing a name as string vs referencing it as bytes32. Why does the totalVotersCount state variable eliminate the need for an array length loop?
Was this lesson helpful?
Let us know what you think of this specification. (submitting anonymously)
