Why error handling matters for performance

Every failed transaction still consumes gas up to the point of failure. If your contract performs expensive work before discovering an invalid condition, users pay for computation that should have been avoided. Good error handling is therefore partly about failing early.

There are two main performance angles:

  1. Runtime cost: how much gas is spent when a check fails or succeeds.
  2. Deployment cost: how much bytecode is added by error strings, repeated checks, and helper logic.

A well-designed error strategy improves both. It also makes contracts easier to integrate with because off-chain systems can decode failures more reliably.

The main Solidity error mechanisms

Solidity provides several ways to signal failure:

  • require(condition, "message")
  • revert("message")
  • revert CustomError(...)
  • assert(condition)

Each has a different purpose.

MechanismTypical useGas characteristicsNotes
requireValidate user input or external stateModerateString messages increase bytecode size
revertAbort execution explicitlySimilar to requireOften paired with custom errors
CustomErrorDomain-specific failure conditionsCheapest for rich errorsBest for production contracts
assertInternal invariants that should never failNot for user errorsIndicates a bug, not bad input

For performance-sensitive contracts, the most important shift is moving from revert strings to custom errors.

Prefer custom errors over revert strings

Revert strings are convenient during development, but they are expensive. The string literal is embedded in bytecode, increasing deployment size. When the revert happens, the string data also needs to be returned, which adds runtime overhead.

Custom errors are more compact and more expressive for production use.

Example: revert string vs custom error

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

contract Vault {
    error InsufficientBalance(uint256 available, uint256 required);

    mapping(address => uint256) private balances;

    function withdraw(uint256 amount) external {
        uint256 available = balances[msg.sender];
        if (available < amount) {
            revert InsufficientBalance(available, amount);
        }

        unchecked {
            balances[msg.sender] = available - amount;
        }
    }
}

This pattern is better than:

require(balances[msg.sender] >= amount, "Insufficient balance");

The custom error version gives you:

  • lower deployment bytecode
  • structured data for frontends and indexers
  • cheaper revert payloads
  • easier debugging in tooling that decodes ABI errors

Best practices for custom errors

  • Put them near the top of the contract or in a shared interface.
  • Use typed fields instead of generic strings.
  • Keep error names short but descriptive.
  • Include only data that is useful for debugging or UI messaging.

A good error should answer: what failed, and what values caused it?

Use require for simple validation, but keep it lean

require is still useful when the condition is simple and the message is temporary or development-only. However, in production code, it should be used sparingly.

Good uses of require

  • validating function arguments
  • checking permissions
  • guarding against zero addresses
  • enforcing preconditions before state changes

Avoid these patterns

  • long revert strings
  • repeated require checks with similar messages
  • validating the same condition multiple times in one call path

If you need a descriptive failure in production, prefer a custom error.

Example: access control check

error NotOwner(address caller);

modifier onlyOwner() {
    if (msg.sender != owner) {
        revert NotOwner(msg.sender);
    }
    _;
}

This is more efficient than:

require(msg.sender == owner, "Only owner can call this function");

The custom error is smaller and gives the caller address directly.

Fail early before expensive operations

A common performance mistake is doing work before verifying that the call is valid. If a transaction is going to fail, it should fail as soon as possible.

Example: validate before looping

function distribute(address[] calldata recipients, uint256 amount) external {
    if (recipients.length == 0) {
        revert EmptyRecipients();
    }

    if (amount == 0) {
        revert ZeroAmount();
    }

    for (uint256 i = 0; i < recipients.length; ++i) {
        _transfer(recipients[i], amount);
    }
}

This is better than checking inside the loop. If the input is invalid, the function exits before any iteration or external work.

Why this matters

Early failure reduces wasted gas in two ways:

  • it avoids executing expensive logic
  • it avoids partially processing state before discovering an invalid condition

This is especially important in functions that:

  • iterate over arrays
  • perform multiple storage writes
  • make external calls
  • compute hashes or signatures

Separate user errors from internal invariants

Not every failure should be handled the same way. Solidity distinguishes between:

  • user-facing validation failures: bad input, unauthorized access, insufficient balance
  • internal invariants: impossible states that indicate a bug

Use custom errors or require for user-facing failures. Use assert only for invariants that should never be violated if the contract is correct.

Example: invariant check

function _mint(address to, uint256 amount) internal {
    uint256 supplyBefore = totalSupply;
    totalSupply += amount;
    balances[to] += amount;

    assert(totalSupply == supplyBefore + amount);
}

If this assertion fails, the contract logic is broken. That is different from a user passing invalid input. Do not use assert for normal validation because it communicates the wrong intent and can make debugging harder.

Design errors for frontends and integrations

A good error strategy is not just about gas. It also improves the developer experience for wallets, dashboards, and automation scripts.

Custom errors are ABI-encoded, which means off-chain code can decode them reliably. This is useful when a frontend wants to display a specific message or when a bot needs to decide whether to retry.

Recommended error design

  • Use one error per failure category.
  • Include relevant context fields.
  • Keep error semantics stable across versions.
  • Avoid overloading one error with too many meanings.

Example error set

error Unauthorized(address caller);
error InvalidDeadline(uint256 deadline);
error SlippageExceeded(uint256 expected, uint256 actual);
error OrderExpired(uint256 timestamp, uint256 deadline);

This is much easier to consume than a generic "execution failed" message.

Choose the right validation order

The order of checks affects gas usage. Put the cheapest and most likely-to-fail checks first.

Practical ordering strategy

  1. Check simple scalar conditions first.
  2. Check permissions next.
  3. Check state-dependent conditions.
  4. Perform expensive calculations last.
  5. Make external calls only after all local validation passes.

Example

function placeOrder(uint256 amount, uint256 deadline) external {
    if (amount == 0) revert ZeroAmount();
    if (block.timestamp > deadline) revert OrderExpired(block.timestamp, deadline);
    if (!isWhitelisted[msg.sender]) revert Unauthorized(msg.sender);

    uint256 fee = _quoteFee(amount);
    if (fee > maxFee) revert FeeTooHigh(fee, maxFee);

    _recordOrder(msg.sender, amount, fee);
}

This sequence avoids computing the fee if the call is already invalid due to amount, deadline, or authorization.

Reduce duplicate checks with internal helpers

Repeated validation logic can bloat bytecode and make maintenance harder. A small internal helper can centralize checks, but only if it does not introduce unnecessary abstraction overhead.

Good pattern: shared internal validation

function _ensureNonZero(uint256 value) internal pure {
    if (value == 0) revert ZeroValue();
}

Use it when the same rule appears in multiple functions. This keeps behavior consistent and reduces the chance of divergent error handling.

Be careful with over-abstraction

Do not create deeply nested helper chains for simple checks. Solidity inlining is not guaranteed in the way developers might expect, and too much indirection can make code harder to audit. Use helpers for shared policy, not for every tiny condition.

Handle external call failures deliberately

When a contract calls another contract, failure can happen for many reasons: revert, out-of-gas, bad return data, or unexpected behavior. A performance-conscious contract should not wrap every external call in heavy logic unless necessary.

Recommendations

  • Validate local conditions before external calls.
  • Keep external call sequences short.
  • Avoid unnecessary try/catch unless you need to recover from failure.
  • If you do use try/catch, decode only the error information you actually need.

Example: minimal recovery path

try oracle.getPrice() returns (uint256 price) {
    if (price == 0) revert InvalidPrice();
    _updatePrice(price);
} catch {
    revert OracleUnavailable();
}

This pattern is useful when the contract can distinguish between a recoverable external failure and a valid response. Do not use try/catch as a default wrapper around every call; it adds complexity and can obscure the main execution path.

Compare common error patterns

PatternBest forProsCons
Revert stringPrototyping, temporary debuggingEasy to readHigher deployment cost
Custom errorProduction validationCheap, structured, ABI-friendlyRequires tooling support
assertInvariantsClear bug signalNot for user input
try/catchExternal call recoveryControlled fallbackMore code, more branching

In production contracts, custom errors are usually the best default.

A practical checklist for production contracts

Use this checklist when reviewing error handling for performance:

  • Replace revert strings with custom errors where possible.
  • Check cheap conditions before expensive work.
  • Validate inputs before loops and external calls.
  • Use assert only for internal invariants.
  • Keep error types specific and stable.
  • Avoid redundant validation across nested functions.
  • Decode external failures only when the caller can act on them.
  • Make sure frontends know how to interpret custom errors.

Putting it all together

A well-optimized Solidity contract does not just compute efficiently; it also fails efficiently. That means rejecting invalid calls early, returning structured errors, and avoiding unnecessary bytecode growth from verbose messages.

The most practical improvement for most teams is to replace revert strings with custom errors and then review the order of validation checks. These two changes often produce immediate gains in deployment size, runtime gas, and integration quality.

If you are building a protocol, marketplace, or vault, treat error handling as part of your performance budget. The best error is the one that prevents wasted execution before it starts.

Learn more with useful resources