
Preventing Denial-of-Service in Solidity with Bounded Loops
What makes bounded loops a security concern?
Ethereum transactions have a finite gas limit. If a function iterates over a list whose size can grow without bound, the cost of execution also grows without bound. Eventually, the function may exceed the block gas limit or become economically impractical to call.
This is a security issue because it can:
- block withdrawals or settlements,
- freeze administrative workflows,
- prevent cleanup or maintenance tasks,
- make a contract impossible to use after enough activity.
The problem is not just “gas optimization.” It becomes a denial-of-service risk when critical functionality depends on completing a loop over user-controlled or naturally growing data.
Typical risky patterns
Common examples include:
- distributing rewards to every participant in one transaction,
- iterating through all stakers to update balances,
- processing every pending request in a queue,
- removing an item from an array by shifting all later elements,
- scanning a large list to find a matching entry.
These patterns may work in testing but fail in production once the dataset grows.
Why unbounded loops fail in practice
A loop can become dangerous in two ways:
- The number of iterations is unbounded
- The contract stores an array or mapping of arbitrary size.
- Anyone can add entries over time.
- A function later tries to process them all at once.
- Each iteration is expensive
- The loop performs storage writes.
- It makes external calls.
- It performs nested loops or repeated searches.
Even if the loop is technically bounded by an array length, the practical limit may still be too large for a single transaction.
Example of a fragile design
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract RewardPool {
address[] public stakers;
mapping(address => uint256) public rewards;
function addStaker(address user) external {
stakers.push(user);
}
function distribute(uint256 amountPerStaker) external {
for (uint256 i = 0; i < stakers.length; i++) {
rewards[stakers[i]] += amountPerStaker;
}
}
}At first glance, this looks simple. But distribute() becomes more expensive as stakers.length grows. Eventually, it may fail entirely. If the distribution is essential, the contract has created a denial-of-service condition.
Design principle: avoid “process everything now”
The safest approach is to stop thinking in terms of “loop over all items in one transaction.” Instead, design around one of these patterns:
- push work to users: each user claims their own reward,
- process in batches: handle a limited number of items per call,
- store cumulative state: compute results lazily instead of repeatedly iterating,
- use checkpoints: resume processing from the last completed index.
The right choice depends on the business logic, but the core idea is the same: no single call should depend on processing an arbitrarily large dataset.
Safer pattern 1: pull-based claims
Instead of distributing rewards to everyone in one loop, record what each account can claim and let users withdraw individually.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract PullRewards {
mapping(address => uint256) public rewards;
function credit(address user, uint256 amount) external {
rewards[user] += amount;
}
function claim() external {
uint256 amount = rewards[msg.sender];
require(amount > 0, "Nothing to claim");
rewards[msg.sender] = 0;
payable(msg.sender).transfer(amount);
}
receive() external payable {}
}Why this is safer
- No loop over all users.
- Each user pays the gas for their own claim.
- The contract remains functional as the user base grows.
Trade-offs
- Users must actively claim.
- Unclaimed balances remain in storage.
- You may need a separate mechanism for expiration or cleanup.
This pattern is often the best choice when the action is naturally user-specific.
Safer pattern 2: batch processing with a cursor
If you truly need to process a list on-chain, do it in chunks. Store a cursor that tracks progress across transactions.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract BatchedDistributor {
address[] public recipients;
mapping(address => uint256) public credits;
uint256 public nextIndex;
function addRecipient(address user) external {
recipients.push(user);
}
function distributeBatch(uint256 amountPerRecipient, uint256 maxItems) external {
uint256 end = nextIndex + maxItems;
if (end > recipients.length) {
end = recipients.length;
}
for (uint256 i = nextIndex; i < end; i++) {
credits[recipients[i]] += amountPerRecipient;
}
nextIndex = end;
}
}Why this helps
- Gas usage is capped per call by
maxItems. - The contract can continue processing even if the list is large.
- Work can be resumed later without repeating completed items.
Important caveat
Never let the caller choose an arbitrary maxItems without considering gas. A malicious or careless caller could still pass a large value and cause the transaction to fail. In practice, you should:
- enforce a reasonable upper bound,
- test worst-case gas usage,
- keep each iteration cheap.
Safer pattern 3: avoid iteration with cumulative accounting
Some loops exist only because the contract repeatedly recomputes totals. Often, you can replace iteration with incremental state.
For example, instead of summing all deposits every time you need the total, maintain a running total as deposits and withdrawals occur.
| Problematic approach | Better approach |
|---|---|
| Recompute totals by looping through all records | Update a stored aggregate on each state change |
| Distribute to all users in one call | Let users claim based on per-user accounting |
| Search arrays repeatedly | Maintain an index or mapping for direct lookup |
| Remove array elements by shifting | Use swap-and-pop or a mapping-based structure |
This is not just more efficient; it is more robust against growth-related failures.
Common anti-patterns to avoid
1. Iterating over user-controlled arrays in state-changing functions
If users can add entries to an array, any function that loops over the whole array is a future DoS risk.
2. Nested loops over dynamic data
A loop inside another loop can become catastrophic very quickly. Even moderate input sizes can exceed gas limits.
3. Cleanup functions that scale with contract age
Functions like clearAll, migrateAll, or settleAll often look harmless during development but become unusable after months of activity.
4. Array deletion by shifting
This pattern is especially dangerous:
for (uint256 i = index; i < items.length - 1; i++) {
items[i] = items[i + 1];
}
items.pop();Deleting one item becomes O(n). Repeating this in a busy contract can create severe gas costs. Prefer swap-and-pop when order does not matter.
Practical refactoring strategies
Use mappings for membership checks
If you need to know whether an address is in a set, do not scan an array every time. Store a mapping:
mapping(address => bool) public isMember;This gives constant-time lookup and avoids repeated iteration.
Keep arrays only for enumeration
Arrays are useful when you truly need ordered iteration, but avoid using them as the primary source of truth for frequently accessed membership or balance data.
Split large tasks into multiple transactions
If a task is operational rather than user-facing, expose a function that can be called repeatedly until completion. Track progress explicitly.
Design for partial completion
A batch function should be safe to call many times. If it fails halfway through, it should not corrupt state or require restarting from scratch.
Testing for gas-related denial of service
Security testing should include gas growth analysis, not just correctness checks.
What to test
- How gas usage changes as the array grows.
- Whether critical functions still succeed at realistic upper bounds.
- Whether a single malicious actor can inflate storage and block others.
- Whether batch sizes are safe under mainnet gas constraints.
Useful test scenarios
- 10 items, 100 items, 1,000 items, 10,000 items.
- Worst-case storage writes per iteration.
- Repeated calls after partial progress.
- Failure behavior when the batch is too large.
If a function’s gas cost scales linearly with state size, ask whether that state size can grow without a hard cap. If yes, the design needs revision.
When bounded loops are acceptable
Not every loop is dangerous. A bounded loop can be reasonable when:
- the upper bound is small and enforced on-chain,
- the list size is fixed by protocol design,
- the loop runs only in administrative functions with strict limits,
- the cost is well below practical gas limits even in the worst case.
For example, iterating over a fixed set of 5 protocol roles is fine. Iterating over an unbounded list of thousands of users is not.
The distinction is whether the bound is real and enforced, not merely assumed.
Checklist for safer contract design
Before deploying a contract, review any loop with this checklist:
- Can the number of iterations grow without bound?
- Can users influence the size of the data being iterated?
- Is the loop inside a state-changing function?
- Does each iteration write to storage or make external calls?
- Can the function still succeed after the contract has been used for a long time?
- Is there a pull-based or batched alternative?
- Is the maximum gas cost acceptable under worst-case conditions?
If you answer “yes” to the first four questions, treat the code as a likely DoS risk.
Conclusion
Denial-of-service vulnerabilities in Solidity are often caused by well-intentioned but unscalable loop designs. The core mistake is assuming that a contract can safely process all accumulated data in one transaction forever. On Ethereum, that assumption eventually breaks.
The most reliable defenses are architectural: use pull payments, batch work with cursors, maintain cumulative state, and avoid loops over unbounded collections. When iteration is unavoidable, keep it tightly bounded and test it against realistic growth scenarios. A contract that remains functional at scale is not just cheaper to run—it is safer to use.
