Common Techniques for Reducing Contract Size
To demonstrate several strategies for optimizing contract size, start by creating two contracts: OptimizedCalculator.sol
and AdvancedCalculator.sol
, using the following structure:
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.19;
contract OptimizedCalculator {
function add(uint256 a, uint256 b) external pure returns (uint256) {
require(a > 0 && b > 0, "Invalid values");
return a + b;
}
function sub(uint256 a, uint256 b) external pure returns (uint256) {
require(a > 0 && b > 0, "Invalid values");
return a - b;
}
function mul(uint256 a, uint256 b) external pure returns (uint256) {
require(a > 0 && b > 0, "Invalid values");
return a * b;
}
function div(uint256 a, uint256 b) external pure returns (uint256) {
require(a > 0 && b > 0, "Invalid values");
return a / b;
}
}
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.19;
import "./OptimizedCalculator.sol";
contract AdvancedCalculator is OptimizedCalculator {
function power(uint256 base, uint256 exponent) public pure returns (uint256) {
require(base > 0 && exponent > 0, "Invalid values");
return base ** exponent;
}
}
Next, execute the command npx hardhat size-contracts
once more, and you should see the following output:

Observe that the size of AdvancedCalculator
is larger than that of OptimizedCalculator
. This increase is due to AdvancedCalculator
inheriting from OptimizedCalculator
, meaning it includes all the functionality and code from the parent contract—which directly impacts its overall size.
Code Abstraction and Modifiers
At this stage, as a smart contract developer, it's a good idea to review your code and identify opportunities for optimization.
One of the first things you'll likely observe in the codebase is the frequent use of require
statements. Instead of writing require(a > 0 && b > 0, "Invalid values");
multiple times, a more efficient approach is to abstract this repetitive logic into a modifier, like the example below:
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.19;
contract OptimizedCalculator {
error InvalidInput();
function add(uint256 a, uint256 b) external pure onlyValidInputs(a, b) returns (uint256) {
return a + b;
}
function sub(uint256 a, uint256 b) external pure onlyValidInputs(a, b) returns (uint256) {
return a - b;
}
function mul(uint256 a, uint256 b) external pure onlyValidInputs(a, b) returns (uint256) {
return a * b;
}
function div(uint256 a, uint256 b) external pure onlyValidInputs(a, b) returns (uint256) {
return a / b;
}
modifier onlyValidInputs(uint256 a, uint256 b) {
if (a == 0 || b == 0) {
revert InvalidInput();
}
_;
}
}
And for AdvancedCalculator
:
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.19;
import "./OptimizedCalculator.sol";
contract AdvancedCalculator is OptimizedCalculator {
function power(uint256 base, uint256 exponent)
public
pure
onlyValidInputs(base, exponent)
returns (uint256)
{
return base ** exponent;
}
}
Take note of how the modifier is used along with the replacement of the require
statement by a custom error, which is a more gas-efficient approach.
After running the npx hardhat size-contracts
command again, you should see the updated size output:

Even though the size reduction is minor, you can already observe some improvement. This optimization process can be repeated until you’re satisfied with the contract’s final size.
Splitting Into Multiple Contracts
A common practice in smart contract development is to break down larger contracts into smaller, modular ones. This isn’t just useful for staying within size limits—it also improves code clarity, promotes better abstraction, and helps avoid redundancy.
From a size optimization standpoint, splitting a large contract into smaller, standalone ones helps ensure each individual contract remains under the Solidity size limit. For instance, if an initial contract is 30 KiB, dividing it into two separate contracts could yield two contracts of around 15 KiB each—both within acceptable limits. However, it’s important to note that this approach may increase gas costs during execution, as calls to external contracts are more expensive.
To demonstrate this, let’s create a contract named Car
with a function called startJourney
:
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.20;
contract Car {
function startJourney() external pure returns (string memory) {
return "The journey has started!";
}
}
In this example, the startJourney
function of the Car
contract depends on specific functionality provided by two separate contracts: Dashboard
for handling speed display logic and Engine
for starting the vehicle.
Engine contract:
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.19;
import "hardhat/console.sol";
contract Engine {
function ignite() external view {
console.log("Engine started");
}
}
Dashboard contract:
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.19;
contract Dashboard {
function showSpeed(uint256 speed) external pure returns (uint256) {
require(speed > 0, "Speed must be greater than 0");
return speed;
}
}
The simplest way for the Car
contract to access both the Dashboard
and Engine
functionalities would be through inheritance. However, as these contracts continue to grow with added features, the overall size of the compiled code will increase. Eventually, you may hit the contract size limit, since all the inherited logic is copied into the Car
contract.
A better approach is to keep each functionality within its own dedicated contract. If the Car
needs to use those features, it can interact with the Dashboard
and Engine
contracts via external calls.
In this scenario, the startJourney
function within the Car
contract needs to call both the Dashboard
and Engine
to complete its operation.
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.20;
import "./Engine.sol";
import "./Dashboard.sol";
contract Car {
Engine private engine;
Dashboard private dashboard;
constructor(address _engine, address _dashboard) {
engine = Engine(_engine);
dashboard = Dashboard(_dashboard);
}
function startJourney() external view {
engine.ignite();
dashboard.showSpeed(100);
}
}
When you run the contract sizer plugin, you'll see the following output:

Observe how the Car
contract remains relatively small in size, yet it can still access the full functionality provided by both the Engine
and Dashboard
contracts.
While this modular approach helps keep each contract within size limits, it's important to note that it may lead to increased gas costs due to external calls—this trade-off is explored in more detail in the Gas Optimization article.
Leveraging Libraries
Libraries are a widely used method for encapsulating and reusing common logic across multiple smart contracts. They can play a major role in reducing contract size and improving code maintainability. In Solidity, libraries can be categorized as either internal or external.
Internal libraries function similarly to inherited contracts—when you use them, their code is copied into the final contract during compilation, which increases the contract’s bytecode size.
On the other hand, external libraries behave differently. Solidity interacts with them through a special low-level operation called delegatecall
, allowing the calling contract to execute the library's code in its own context. Because external libraries are stateless, they behave much like pure functions and can be deployed once and reused by multiple contracts.
In the following example, the Car
contract will make use of a Calculator
library only. Here’s how that would look:
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.20;
library Calculator {
error InvalidInput();
modifier onlyValidInputs(uint256 a, uint256 b) {
if (a == 0 || b == 0) {
revert InvalidInput();
}
_;
}
function add(uint256 a, uint256 b) external pure onlyValidInputs(a, b) returns (uint256) {
return a + b;
}
function sub(uint256 a, uint256 b) external pure onlyValidInputs(a, b) returns (uint256) {
return a - b;
}
function mul(uint256 a, uint256 b) external pure onlyValidInputs(a, b) returns (uint256) {
return a * b;
}
function div(uint256 a, uint256 b) external pure onlyValidInputs(a, b) returns (uint256) {
return a / b;
}
}
Than Car is:
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.19;
import "./Engine.sol";
import "./Dashboard.sol";
import "./Calculator.sol";
contract Car {
using Calculator for uint256;
Engine private engine;
Dashboard private dashboard;
uint256 private speed;
constructor(address _engine, address _dashboard) {
engine = Engine(_engine);
dashboard = Dashboard(_dashboard);
}
function startJourney() external {
engine.ignite();
uint256 localSpeed = dashboard.showSpeed(100);
speed = speed.add(localSpeed);
}
function getSpeed() external view returns (uint256) {
return speed;
}
}
Observe how the contract is instructed to use the Calculator
library for uint256
types. This allows the add
function from the Calculator
library to be used directly on any uint256
value within the startJourney
function.
After running the npx hardhat size-contracts
command, you will see the following output:

Enabling the Solidity Compiler Optimizer
An additional method to reduce smart contract size is by enabling the Solidity optimizer.
According to the official Solidity documentation: The optimizer works by simplifying complex expressions, which helps lower both the size of the compiled code and its execution cost.
To activate the optimizer in Hardhat, simply add the following configuration to your hardhat.config.ts
file:
require("@nomicfoundation/hardhat-toolbox");
require("hardhat-contract-sizer");
require("dotenv").config();
module.exports = {
solidity: {
version: "0.8.20",
settings: {
optimizer: {
enabled: true,
runs: 200,
},
evmVersion: "paris",
},
},
gasReporter: {
enabled: true,
},
networks: {
ICBTestnet: {
url: "https://rpc1-testnet.icbnetwork.info",
chainId: 73114,
accounts: process.env.PRIVATE_KEY ? [process.env.PRIVATE_KEY] : [],
},
ICBMainnet: {
url: "https://rpc2-mainnet.icbnetwork.info",
chainId: 73115,
accounts: process.env.PRIVATE_KEY ? [process.env.PRIVATE_KEY] : [],
},
},
};
With 1,000 runs result will be:

We decrease the number of runs from 1,000 to 200 and you can see some improvements for the Car
contract:

The contract size may have increased, but this trade-off typically leads to better runtime efficiency. A higher runs
value in the optimizer setting makes execution cheaper, although it results in a more expensive deployment. For more details, refer to the official Solidity documentation.
Final Thoughts
In this tutorial, you explored how to analyze and reduce smart contract sizes using the Hardhat development environment alongside the Hardhat Contract Sizer plugin. By understanding the impact of contract size, you've gained practical tools and techniques to write more efficient and maintainable Solidity code.
As you progress in your smart contract development journey, remember that optimizing for size is an ongoing process. Balancing bytecode size, gas costs, and code readability requires thoughtful design decisions throughout the development lifecycle.
Last updated