Shrink your state, byte by byte
Shrink NEAR contract state to cut storage staking: Borsh sizes, short prefixes, numbers instead of strings, u64 vs u128, and per-entry byte math in NEAR.
Intermediate5 min read3-question check
Every stored byte keeps 10¹⁹ yoctoNEAR locked, and whatever you store per entry is multiplied by the number of entries. A few bytes saved on a record that exists once per user is real money at scale, and it also means less data to read and write on every call.
What one entry really costs#
The protocol counts each storage record as key length + value length + 40 bytes of fixed overhead (the num_extra_bytes_record runtime parameter). For a store::LookupMap the key is the collection prefix followed by the Borsh-encoded key.
Worked example: a LookupMap<AccountId, u128> with a 1-byte prefix and the key alice.near takes 1 + (4 + 10) bytes of key, 16 bytes of value and 40 bytes of overhead, 71 bytes in total. That is 71 × 10¹⁹ yoctoNEAR, about 0.00071 NEAR. A million such accounts lock roughly 710 NEAR; with 64-character implicit account IDs each entry grows to 125 bytes.
| Type | Bytes |
|---|---|
bool, u8 | 1 |
u32 | 4 |
u64 | 8 |
u128 (token amounts) | 16 |
String, AccountId | 4-byte length + UTF-8 bytes |
Vec<T> | 4-byte length + each item |
[u8; 32] (e.g. a hash) | 32, no length prefix |
Option<T> | 1 tag byte, plus T when Some |
| enum | 1 tag byte + the variant’s fields |
Habits that save bytes#
- Store numbers as numbers. One NEAR in yoctoNEAR as a
u128is 16 bytes; as the decimal string"1000000000000000000000000"it is 4 + 25 = 29 bytes. UseU128only at the JSON boundary. - Right-size integers. Counters and indexes rarely need more than
u32; nanosecond timestamps fit inu64. Reserveu128for token amounts. - Keep prefixes short. The prefix is repeated in every key: a one-byte enum prefix beats
b"user_balances_v2"on every single entry. - Do not repeat the key in the value. A map keyed by
AccountIddoes not need the account ID inside the value struct as well. - Group fields that are read together. Three maps keyed by the same account pay the key and the 40-byte overhead three times; one map of a small struct pays them once.
- Use enums for states. A status enum is 1 byte; the string
"pending"is 11. - Do not store what you can derive cheaply in the same call, and keep large blobs (images, long text) off-chain, storing a 32-byte hash or a short URL instead.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract Club {
struct Member {
uint64 joinedAt; // these three fields are packed
uint32 points; // into a single 32-byte slot
bool active;
}
mapping(address => Member) public members;
}use near_sdk::store::LookupMap;
use near_sdk::{near, AccountId};
#[near(serializers = [borsh])]
pub struct Member {
joined_at: u64, // 8 bytes
points: u32, // 4 bytes
active: bool, // 1 byte
} // 13 bytes, no padding
#[near(contract_state)]
pub struct Club {
members: LookupMap<AccountId, Member>,
}
impl Default for Club {
fn default() -> Self {
Self { members: LookupMap::new(b"m") }
}
}Solidity packs fields into 32-byte slots because you pay gas per slot written. Borsh has no slots and no padding: each field costs exactly its size.
The difference is what that size costs: on Ethereum a one-off write fee, on NEAR NEAR locked for as long as the record exists, refundable when it is deleted.
Check yourself
3 questions · progress saved in this browser