Load only what a call needs
Keep NEAR contract root state small to save gas: LazyOption for big values, store collections that load per key, and caching reads within a single call.
Advanced7 min read3-question check
Every call into your contract starts the same way: near-sdk reads the root state record and Borsh-deserializes the whole contract struct. Every change call (&mut self) ends the same way: the struct is serialized and written back. The size of that struct is a tax on every method, including the cheap ones.
So the goal is a small root struct whose fields are mostly handles, with the real data in separate storage keys that a call loads only when it touches them.
Keep the root struct small#
use near_sdk::json_types::U128;
use near_sdk::store::{LazyOption, LookupMap};
use near_sdk::{near, AccountId, BorshStorageKey, PanicOnDefault};
#[near(serializers = [borsh, json])]
#[derive(Clone)]
pub struct Metadata {
name: String,
description: String, // can be kilobytes
icon: Option<String>, // a data: URL, often bigger still
}
#[near(serializers = [borsh])]
#[derive(BorshStorageKey)]
enum StorageKey {
Metadata,
Balances,
}
#[near(contract_state)]
#[derive(PanicOnDefault)]
pub struct Token {
owner: AccountId,
total_supply: u128,
metadata: LazyOption<Metadata>, // own storage key, loaded on access
balances: LookupMap<AccountId, u128>, // one entry per key, loaded on access
}
#[near]
impl Token {
#[init]
pub fn new(owner: AccountId, metadata: Metadata) -> Self {
Self {
owner,
total_supply: 0,
metadata: LazyOption::new(StorageKey::Metadata, Some(metadata)),
balances: LookupMap::new(StorageKey::Balances),
}
}
// never pays to load the metadata
pub fn balance_of(&self, account_id: AccountId) -> U128 {
U128(self.balances.get(&account_id).copied().unwrap_or(0))
}
pub fn metadata(&self) -> Option<Metadata> {
self.metadata.get().clone()
}
}With this layout the root record holds an account ID, a u128 and two short prefixes. balance_of and any transfer method never deserialize the metadata; only metadata() reads it. A LazyOption stores its value under its own key, loads it on first access, and keeps it cached for the rest of the call.
Use store::Lazy<T> for the same trick when the value is always present, and store collections for anything that grows with the number of users.
Cache within a call#
// same Token contract, plus: use near_sdk::{env, require};
#[near]
impl Token {
pub fn credit(&mut self, account_id: AccountId, amount: U128) {
require!(env::predecessor_account_id() == self.owner, "owner only");
let balance = self.balances.entry(account_id).or_insert(0);
*balance += amount.0;
self.total_supply += amount.0;
}
}near_sdk::store collections cache what they read and buffer what they write, so reading the same key twice in one call does not hit storage twice, and repeated updates to one key become a single write at the end. The legacy near_sdk::collections types do not cache: every get is a fresh storage read and deserialization. Either way, the clearest habit is the same: read a value once, work on it locally, write it once.
Check yourself
3 questions · progress saved in this browser