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:

  1. 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.
  1. 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 approachBetter approach
Recompute totals by looping through all recordsUpdate a stored aggregate on each state change
Distribute to all users in one callLet users claim based on per-user accounting
Search arrays repeatedlyMaintain an index or mapping for direct lookup
Remove array elements by shiftingUse 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.

Learn more with useful resources