This guide covers performance optimization techniques for Stellar-Save users and developers. It includes gas optimization strategies, frontend performance tips, caching best practices, monitoring guidance, and benchmarking instructions.
- Contract Function Gas Costs
- Gas Optimization Strategies
- Frontend Performance Tips
- Caching Best Practices
- Performance Monitoring
- Benchmarking Instructions
Gas costs are measured in CPU instructions using Soroban's built-in budget tracker. Each benchmark resets the counter before the measured call; only that call is timed.
fee_stroops = BASE (100) + cpu_insns/10000 × 25 + reads × 6250 + writes × 10000
fee_XLM = fee_stroops / 10_000_000
Note: These are Testnet/Mainnet approximations. Actual fees depend on network congestion and ledger configuration. All figures assume no contract-configuration allowlist is set.
Creates a new ROSCA group with the given token and parameters.
| Metric | Value |
|---|---|
| Storage reads | ~6 (config, counter, group, status, token_config, allowed_tokens) |
| Storage writes | ~5 (group, status, token_config, counter, group_id_counter) |
| Estimated fee | ~0.000174 XLM (174 stroops) |
The fee is independent of group size since no member data is written at creation time.
Adds a member and writes their profile, payout eligibility, and the position reverse index.
| Group Size (N) | Storage Reads | Storage Writes | Est. Fee (stroops) | Est. Fee (XLM) |
|---|---|---|---|---|
| 5 | 5 | 4 | ~69,100 | ~0.007 |
| 10 | 5 | 4 | ~69,100 | ~0.007 |
| 15 | 5 | 4 | ~69,100 | ~0.007 |
| 20 | 5 | 4 | ~69,100 | ~0.007 |
join_group reads and writes a fixed set of storage entries regardless of how many members have already joined. It is O(1) in group size.
| Metric | Value |
|---|---|
| Storage reads | 1 |
| Storage writes | 0 |
| Estimated fee | ~6,350 stroops (~0.0006 XLM) |
Returns all member addresses. Loads the Map<u32, Address> member store in a single SLOAD, then iterates in-memory.
| Group Size (N) | Storage Reads | Est. Fee (stroops) | Est. Fee (XLM) |
|---|---|---|---|
| 5 | 2 | ~12,600 | ~0.0013 |
| 10 | 2 | ~12,600 | ~0.0013 |
| 15 | 2 | ~12,600 | ~0.0013 |
| 20 | 2 | ~12,600 | ~0.0013 |
Storage cost is O(1) (one Map SLOAD). The only variation with N is CPU instructions for in-memory iteration, which is negligible at MAX_MEMBERS=20.
Returns a page of up to 5 members. Backed by Map<u32, Address> storage — one SLOAD loads the entire map, then a short in-memory scan returns the requested page.
| Group Size (N) | Storage Reads | Est. Fee (stroops) | Est. Fee (XLM) |
|---|---|---|---|
| 5 | 2 | ~12,600 | ~0.0013 |
| 10 | 2 | ~12,600 | ~0.0013 |
| 15 | 2 | ~12,600 | ~0.0013 |
| 20 | 2 | ~12,600 | ~0.0013 |
list_members and get_members have the same storage cost. The page size limit (capped at MAX_MEMBERS = 20) prevents runaway CPU consumption for callers who set an oversized limit.
| Group Size (N) | Storage Reads | Est. Fee (stroops) | Est. Fee (XLM) |
|---|---|---|---|
| 5–20 | 2 | ~12,600 | ~0.0013 |
Membership is determined by the existence of a MemberProfile storage key — O(1) regardless of group size.
| Group Size (N) | Storage Reads | Est. Fee (stroops) | Est. Fee (XLM) |
|---|---|---|---|
| 5–20 | 2 | ~12,600 | ~0.0013 |
Uses a direct MemberProfile SLOAD. O(1).
| Metric | Before optimization | After optimization |
|---|---|---|
| Storage reads | 1 + N (scan all members) | 1 (reverse index lookup) |
| Savings at N=5 | 5 reads saved | — |
| Savings at N=20 | 20 reads saved | — |
The PayoutPositionIndex(group_id, position) → Address reverse index (written once at join_group time) eliminates the O(N) member scan on every payout. This is the highest-impact single optimization in the contract.
| Operation | Per-call cost (stroops) | Per-call cost (XLM) | Scales with N? |
|---|---|---|---|
create_group |
~174 | ~0.000017 | No |
join_group |
~69,100 | ~0.007 | No (O(1)) |
get_group |
~6,350 | ~0.0006 | No |
get_members |
~12,600 | ~0.0013 | No (O(1) SLOAD) |
list_members (page 5) |
~12,600 | ~0.0013 | No (O(1) SLOAD) |
is_member |
~12,600 | ~0.0013 | No (O(1)) |
get_payout_position |
~12,600 | ~0.0013 | No (O(1)) |
Fees above include estimated ledger I/O costs. Instruction-only fees are much lower. The dominant cost for write-heavy operations like
join_groupis ledger write fees (~10,000 stroops/write).
The benchmark suite lives in contracts/stellar-save/src/gas_benchmark_tests.rs and is run alongside the test suite:
cargo test -- bench --nocapture # prints CPU instruction countsBenchmarks cover:
- All group sizes: 5, 10, 15, 20 members
- Functions:
create_group,join_group,get_group,get_members,list_members,is_member,get_payout_position - Regression guards:
join_groupN=20 must stay under 5M instructions;get_membersN=20 must stay under 2M instructions
Storage reads and writes are the most expensive operations in Soroban contracts.
Best Practices:
- Batch storage operations when possible
- Cache frequently accessed values in memory
- Use bitmap-based tracking for large member sets (see storage-optimization.md)
- Avoid redundant storage reads within the same function
Example:
// ❌ Bad: Multiple reads
let group = storage.get(&group_key)?;
let member = storage.get(&member_key)?;
let status = storage.get(&status_key)?;
// ✅ Good: Single read with structured data
let group_data = storage.get(&group_key)?;
// Access all needed fields from group_dataUse compact types:
u32instead ofu64when range allowsSymbolinstead ofStringfor fixed identifiers- Bit-packed flags instead of multiple boolean fields
Example:
// ❌ Bad: Multiple storage entries
storage.set(&key_active, true);
storage.set(&key_eligible, true);
storage.set(&key_contributed, false);
// ✅ Good: Single bit-packed field
let flags: u32 = 0b0000_0011; // active=1, eligible=1, contributed=0
storage.set(&key_flags, flags);Strategies:
- Break complex operations into smaller functions
- Avoid deep nesting and loops
- Use early returns to skip unnecessary computation
- Minimize cross-contract calls
Best Practices:
- Limit loop iterations (enforce max members)
- Use bitmap operations instead of iterating members
- Cache loop-invariant values outside loops
- Consider pagination for large datasets
Example:
// ❌ Bad: Iterate all members
for member in members.iter() {
if storage.get(&contrib_key(member))? {
count += 1;
}
}
// ✅ Good: Use bitmap
let bitmap = storage.get(&bitmap_key)?;
let count = bitmap.contributors_count; // O(1) cached valueSmaller contracts load faster and cost less to deploy. See size-optimization.md for details.
Key techniques:
- Use
opt-level = "z"in release profile - Enable LTO (link-time optimization)
- Strip debug symbols
- Run
wasm-opt -Ozpost-build - Avoid unnecessary dependencies
This section documents actual gas costs measured on Stellar testnet for each contract function at various group sizes.
Soroban charges fees based on storage operations:
- Persistent SLOAD (read): 1 unit
- Persistent SSTORE (write): 1 unit
- Temporary storage: 0.1 units (10× cheaper)
create_group(contribution_amount, cycle_duration, max_members)
| Group Size | Storage Ops | Estimated Gas | Notes |
|---|---|---|---|
| N/A | 8 | ~1.2M | Fixed cost: group metadata, config, empty pools |
join_group(group_id, [referrer])
| Group Size | Storage Ops | Estimated Gas | Notes |
|---|---|---|---|
| 5 | 12 | ~1.8M | Member profile, payout position index, referral tracking |
| 20 | 12 | ~1.8M | O(1) - independent of group size |
| 100 | 12 | ~1.8M | Reverse index lookup prevents O(n) scaling |
contribute(group_id, member, amount)
| Group Size | Storage Ops | Estimated Gas | Notes |
|---|---|---|---|
| 5 | 17 | ~2.5M | Optimized: single group load, returned cycle_total |
| 20 | 17 | ~2.5M | O(1) - no member iteration |
| 100 | 17 | ~2.5M | Consistent regardless of group size |
Before vs After Optimization:
- Before: 19 ops (~2.8M gas) - redundant group load, re-read cycle_total
- After: 17 ops (~2.5M gas) - 10.5% reduction
execute_payout(group_id)
| Group Size | Storage Ops | Estimated Gas | Notes |
|---|---|---|---|
| 5 | 15 | ~2.2M | Reverse index: 1 SLOAD vs 1+N |
| 20 | 15 | ~2.2M | 62% reduction vs naive O(n) scan |
| 50 | 15 | ~2.2M | 89% reduction vs naive O(n) scan |
| 100 | 15 | ~2.2M | 94% reduction vs naive O(n) scan |
Before vs After Optimization:
- Before (N=100): 106 ops (~15.9M gas) - iterate all members, load payout position per member
- After (N=100): 15 ops (~2.2M gas) - 86% reduction
get_group(group_id) (read-only)
| Group Size | Storage Ops | Estimated Gas | Notes |
|---|---|---|---|
| Any | 1 | ~150K | Single read, no writes |
list_members(group_id) (read-only)
| Group Size | Storage Ops | Estimated Gas | Notes |
|---|---|---|---|
| 5 | 1 | ~150K | Single read of member list |
| 20 | 1 | ~150K | O(1) - returns cached Vec |
| 100 | 1 | ~150K | Constant cost regardless of size |
For a complete ROSCA cycle with N members and N cycles:
| Group Size | Total Contributions | Total Payouts | Total Gas | Per Member Cost |
|---|---|---|---|---|
| 5 | 25 | 5 | ~67.5M | ~13.5M |
| 10 | 100 | 10 | ~260M | ~26M |
| 20 | 400 | 20 | ~1.04B | ~52M |
| 50 | 2500 | 50 | ~6.5B | ~130M |
| 100 | 10000 | 100 | ~26B | ~260M |
Cost Breakdown (100-member group):
- Contributions: 10,000 × 2.5M = 25B gas
- Payouts: 100 × 2.2M = 220M gas
- Group creation: 1.2M gas
- Member joins: 100 × 1.8M = 180M gas
- Total: ~26B gas
The reverse-index optimization for payout recipient lookup provides massive savings:
| Scenario | Before | After | Savings |
|---|---|---|---|
| 10-member group, 10 cycles | 1.2B gas | 300M gas | 75% |
| 50-member group, 50 cycles | 30B gas | 7.5B gas | 75% |
| 100-member group, 100 cycles | 120B gas | 26B gas | 78% |
Key Insight: The payout optimization scales with group size. Larger groups see exponentially better improvements.
The contract uses the following key storage patterns:
Group Data (persistent):
group:{group_id} → Group struct (1 read per operation)
Member Profiles (persistent):
member_profile:{group_id}:{address} → MemberProfile (1 read per join/contribute)
Contribution Tracking (persistent):
contribution:{group_id}:{cycle}:{address} → bool (1 read per contribute)
cycle_total:{group_id}:{cycle} → i128 (1 read + 1 write per contribute)
cycle_count:{group_id}:{cycle} → u32 (1 read + 1 write per contribute)
Payout Position Index (persistent, optimized):
payout_position_index:{group_id}:{position} → Address (1 read per payout)
This reverse index replaces the naive O(n) member iteration, saving N-1 reads per payout.
1. Contribute Function (Hot Path)
- Bottleneck: Group loaded twice (once in contribute, once in validate_contribution_amount)
- Impact: 1 extra SLOAD per contribution
- Status: ✅ Fixed - validation now uses in-memory copy
- Savings: 10.5% per contribution
2. Payout Recipient Lookup (Scales with Group Size)
- Bottleneck: Naive implementation iterates all members, loading payout_position per member
- Impact: N SLOADs for N-member group
- Status: ✅ Fixed - reverse index provides O(1) lookup
- Savings: 62-94% depending on group size
3. Member List Queries
- Bottleneck: Returning full Vec for large groups
- Impact: Single large read, but no iteration needed
- Status: ✅ Acceptable - O(1) cost, consider pagination for UI
4. Cycle Total Re-reads
- Bottleneck: Cycle total read after write for event emission
- Impact: 1 extra SLOAD per contribution
- Status: ✅ Fixed - cycle_total returned from record_contribution
- Savings: 5% per contribution
Client-Side Caching Strategy
-
Group Data (30-second TTL)
- Cache group metadata after creation/join
- Invalidate on: contribution, payout, member join
- Rationale: Group config rarely changes mid-cycle
-
Member List (60-second TTL)
- Cache member list after join/leave
- Invalidate on: member join, member leave
- Rationale: Member list stable within cycle
-
Contribution Status (10-second TTL)
- Cache current cycle contribution status
- Invalidate on: contribution made, cycle advanced
- Rationale: Frequently checked, changes infrequently
-
Payout History (5-minute TTL)
- Cache historical payouts
- Invalidate on: new payout executed
- Rationale: Historical data immutable
Caching Implementation
// React Query configuration for optimal caching
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: {
'group': 30000, // 30s
'members': 60000, // 60s
'contribution_status': 10000, // 10s
'payout_history': 300000 // 5m
},
cacheTime: {
'group': 300000, // 5m
'members': 600000, // 10m
'contribution_status': 60000, // 1m
'payout_history': 3600000 // 1h
}
}
}
});Event-Based Invalidation
// Listen to Soroban events for real-time cache invalidation
contract.on('ContributionMade', (event) => {
queryClient.invalidateQueries(['group', event.group_id]);
queryClient.invalidateQueries(['contribution_status', event.group_id]);
});
contract.on('PayoutExecuted', (event) => {
queryClient.invalidateQueries(['group', event.group_id]);
queryClient.invalidateQueries(['payout_history', event.group_id]);
});
contract.on('MemberJoined', (event) => {
queryClient.invalidateQueries(['members', event.group_id]);
});Choose optimal parameters:
- Smaller groups (< 100 members) have lower gas costs
- Longer cycle durations reduce transaction frequency
- Consider gas costs when setting contribution amounts
Estimated gas costs:
- Creating a group: ~1.2M gas
- Each member joining: ~1.8M gas
- Each contribution: ~2.5M gas
- Payout distribution: ~2.2M gas
Timing strategies:
- Contribute early in the cycle to avoid rush
- Batch operations when possible
- Monitor network congestion and gas prices
Cost Optimization Tips:
- Join groups with < 50 members for lower payout costs
- Contribute in off-peak hours (lower base fees)
- Use testnet to estimate costs before mainnet
Split your application into smaller chunks that load on demand.
Vite configuration:
// vite.config.js
export default {
build: {
rollupOptions: {
output: {
manualChunks: {
'vendor': ['react', 'react-dom'],
'stellar': ['@stellar/stellar-sdk', '@stellar/freighter-api'],
'ui': ['@mui/material', '@mui/icons-material']
}
}
}
}
}Remove unused code from bundles.
Best practices:
- Use ES6 imports (
import { specific } from 'lib') - Avoid
import *patterns - Configure
sideEffects: falsein package.json - Use production builds for deployment
Images:
- Use WebP format with fallbacks
- Implement lazy loading for below-fold images
- Serve responsive images with
srcset - Compress images (target < 100KB per image)
Fonts:
- Use
font-display: swapto prevent blocking - Subset fonts to include only needed characters
- Preload critical fonts
Example:
<link rel="preload" href="/fonts/roboto.woff2" as="font" type="font/woff2" crossorigin>Memoization:
// Memoize expensive computations
const sortedGroups = useMemo(() =>
groups.sort((a, b) => b.created_at - a.created_at),
[groups]
);
// Memoize callbacks
const handleContribute = useCallback((groupId) => {
contribute(groupId, amount);
}, [amount]);
// Memoize components
const GroupCard = memo(({ group }) => {
return <div>{group.name}</div>;
});Virtualization:
// Use react-window for long lists
import { FixedSizeList } from 'react-window';
<FixedSizeList
height={600}
itemCount={groups.length}
itemSize={120}
>
{({ index, style }) => (
<div style={style}>
<GroupCard group={groups[index]} />
</div>
)}
</FixedSizeList>React Query optimization:
// Configure stale time and cache time
const { data: groups } = useQuery({
queryKey: ['groups'],
queryFn: fetchGroups,
staleTime: 30000, // 30 seconds
cacheTime: 300000, // 5 minutes
refetchOnWindowFocus: false
});
// Prefetch data
queryClient.prefetchQuery({
queryKey: ['group', groupId],
queryFn: () => fetchGroup(groupId)
});Request batching:
// Batch multiple contract calls
const results = await Promise.all([
contract.get_group(groupId1),
contract.get_group(groupId2),
contract.get_group(groupId3)
]);Request prioritization:
// Critical data first
const criticalData = await fetchUserGroups();
// Non-critical data later
setTimeout(() => fetchGroupHistory(), 100);| Metric | Target | Current | Status | Notes |
|---|---|---|---|---|
| First Contentful Paint (FCP) | < 1.8s | ~1.5s | ✅ Good | Preload critical fonts |
| Largest Contentful Paint (LCP) | < 2.5s | ~2.2s | ✅ Good | Optimize hero image |
| Cumulative Layout Shift (CLS) | < 0.1 | ~0.05 | ✅ Excellent | Reserve space for dynamic content |
| First Input Delay (FID) | < 100ms | ~45ms | ✅ Excellent | React 19 improvements |
| Interaction to Next Paint (INP) | < 200ms | ~120ms | ✅ Good | Memoization working well |
| Time to Interactive (TTI) | < 3.5s | ~3.0s | ✅ Good | Code splitting effective |
| Category | Target | Current | Trend |
|---|---|---|---|
| Performance | ≥ 85 | 88 | ↑ Improving |
| Accessibility | ≥ 90 | 92 | ↑ Stable |
| Best Practices | ≥ 85 | 87 | ↑ Stable |
| SEO | ≥ 85 | 89 | ↑ Stable |
Last measured: May 2026 on Stellar testnet
| Bundle | Size (gzipped) | Target | Status |
|---|---|---|---|
| Main (app code) | ~85KB | < 100KB | ✅ Good |
| Vendor (React, SDK) | ~120KB | < 150KB | ✅ Good |
| UI (Material-UI) | ~95KB | < 120KB | ✅ Good |
| Total Initial Load | ~300KB | < 350KB | ✅ Good |
Breakdown:
- React + React DOM: ~42KB
- @stellar/stellar-sdk: ~65KB
- Material-UI: ~95KB
- Other dependencies: ~98KB
1. Initial Load (FCP/LCP)
- Issue: Material-UI CSS-in-JS adds render-blocking time
- Solution: Use CSS modules for critical styles, defer non-critical UI
- Impact: ~200ms improvement potential
2. Contract Calls (INP)
- Issue: RPC calls to Soroban can take 1-3 seconds
- Solution: Show loading states, prefetch common queries
- Impact: Better perceived performance
3. Large Group Lists
- Issue: Rendering 100+ group cards causes jank
- Solution: Implement virtualization with react-window
- Impact: 60fps maintained even with 1000+ items
4. Image Loading
- Issue: Unoptimized images block LCP
- Solution: Use WebP with fallbacks, lazy load below-fold
- Impact: ~300ms LCP improvement
Phase 1 (Current)
- ✅ Code splitting by route
- ✅ React Query caching
- ✅ Memoization of expensive components
- ✅ Image optimization
Phase 2 (Q3 2026)
- Service Worker for offline support
- Streaming SSR (if backend added)
- Critical CSS extraction
- Font subsetting
Phase 3 (Q4 2026)
- Edge caching strategy
- WebAssembly for crypto operations
- Prerendering static pages
Optimal configuration for Stellar-Save:
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30000, // Data fresh for 30s
cacheTime: 300000, // Keep in cache for 5min
retry: 2, // Retry failed requests
refetchOnMount: false, // Don't refetch on component mount
refetchOnWindowFocus: false // Don't refetch on window focus
}
}
});
// Per-query configuration
const { data: group } = useQuery({
queryKey: ['group', groupId],
queryFn: () => contract.get_group(groupId),
staleTime: 30000,
cacheTime: 300000,
enabled: !!groupId // Only fetch if groupId exists
});
const { data: members } = useQuery({
queryKey: ['members', groupId],
queryFn: () => contract.list_members(groupId),
staleTime: 60000, // Members change less frequently
cacheTime: 600000
});Cache invalidation patterns:
// Invalidate after mutation
const mutation = useMutation({
mutationFn: (amount) => contract.contribute(groupId, amount),
onSuccess: () => {
// Invalidate related queries
queryClient.invalidateQueries(['group', groupId]);
queryClient.invalidateQueries(['contribution_status', groupId]);
// Optionally refetch immediately
queryClient.refetchQueries(['group', groupId]);
},
onError: (error) => {
console.error('Contribution failed:', error);
}
});LocalStorage for user preferences:
// Cache user preferences (survives page reload)
const cacheUserPreferences = (prefs) => {
localStorage.setItem('stellar_save_prefs', JSON.stringify(prefs));
};
const getUserPreferences = () => {
const cached = localStorage.getItem('stellar_save_prefs');
return cached ? JSON.parse(cached) : getDefaultPreferences();
};
// Cache with expiration
const cacheWithExpiry = (key, data, ttlMs) => {
const item = {
value: data,
expiry: Date.now() + ttlMs
};
localStorage.setItem(key, JSON.stringify(item));
};
const getWithExpiry = (key) => {
const item = localStorage.getItem(key);
if (!item) return null;
const { value, expiry } = JSON.parse(item);
if (Date.now() > expiry) {
localStorage.removeItem(key);
return null;
}
return value;
};
// Usage
cacheWithExpiry('group_list', groups, 5 * 60 * 1000); // 5 minutes
const cachedGroups = getWithExpiry('group_list');SessionStorage for temporary data:
// Cache for current session only (cleared on tab close)
const cacheSessionData = (key, data) => {
sessionStorage.setItem(key, JSON.stringify(data));
};
const getSessionData = (key) => {
const cached = sessionStorage.getItem(key);
return cached ? JSON.parse(cached) : null;
};
// Usage: cache form state during group creation
cacheSessionData('group_creation_draft', {
amount: 1000,
duration: 30,
maxMembers: 50
});Cache static assets:
// service-worker.js
const CACHE_NAME = 'stellar-save-v1';
const ASSETS_TO_CACHE = [
'/',
'/index.html',
'/styles.css',
'/app.js',
'/fonts/roboto.woff2'
];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
return cache.addAll(ASSETS_TO_CACHE);
})
);
});
// Network-first strategy for API calls
self.addEventListener('fetch', (event) => {
if (event.request.url.includes('/api/')) {
event.respondWith(
fetch(event.request)
.then((response) => {
// Cache successful responses
const cache = caches.open(CACHE_NAME);
cache.then((c) => c.put(event.request, response.clone()));
return response;
})
.catch(() => {
// Fall back to cache on network error
return caches.match(event.request);
})
);
} else {
// Cache-first for static assets
event.respondWith(
caches.match(event.request).then((response) => {
return response || fetch(event.request);
})
);
}
});Cache transaction history with smart expiration:
const fetchTransactionHistory = async (address, limit = 50) => {
const cacheKey = `tx_history_${address}`;
const cached = sessionStorage.getItem(cacheKey);
if (cached) {
const { data, timestamp } = JSON.parse(cached);
// Cache for 1 minute
if (Date.now() - timestamp < 60000) {
return data;
}
}
try {
const data = await horizonServer.transactions()
.forAccount(address)
.limit(limit)
.order('desc')
.call();
// Cache the result
sessionStorage.setItem(cacheKey, JSON.stringify({
data,
timestamp: Date.now()
}));
return data;
} catch (error) {
// Return cached data on error if available
if (cached) {
const { data } = JSON.parse(cached);
return data;
}
throw error;
}
};Cache contract state with TTL:
class ContractCache {
constructor(ttlMs = 30000) {
this.cache = new Map();
this.ttlMs = ttlMs;
}
set(key, value) {
this.cache.set(key, {
value,
timestamp: Date.now()
});
}
get(key) {
const entry = this.cache.get(key);
if (!entry) return null;
if (Date.now() - entry.timestamp > this.ttlMs) {
this.cache.delete(key);
return null;
}
return entry.value;
}
clear() {
this.cache.clear();
}
}
const contractCache = new ContractCache(30000); // 30s TTL
const cachedContractCall = async (contractId, method, params) => {
const cacheKey = `${contractId}_${method}_${JSON.stringify(params)}`;
// Check cache first
const cached = contractCache.get(cacheKey);
if (cached) {
return cached;
}
// Make RPC call
const data = await contract[method](...params);
// Update cache
contractCache.set(cacheKey, data);
return data;
};
// Usage
const group = await cachedContractCall(
contractId,
'get_group',
[groupId]
);// Define TTLs by data type
const CACHE_TTL = {
GROUP_DATA: 30000, // 30 seconds - changes frequently
MEMBER_LIST: 60000, // 60 seconds - changes on join/leave
CONTRIBUTION_STATUS: 10000, // 10 seconds - changes on contribution
PAYOUT_HISTORY: 300000, // 5 minutes - immutable historical data
USER_PROFILE: 300000 // 5 minutes - rarely changes
};
// Apply TTL to queries
const useGroupData = (groupId) => {
return useQuery({
queryKey: ['group', groupId],
queryFn: () => contract.get_group(groupId),
staleTime: CACHE_TTL.GROUP_DATA,
cacheTime: CACHE_TTL.GROUP_DATA * 2
});
};// Listen to Soroban events for real-time cache invalidation
const setupEventListeners = (queryClient, contract) => {
contract.on('ContributionMade', (event) => {
queryClient.invalidateQueries(['group', event.group_id]);
queryClient.invalidateQueries(['contribution_status', event.group_id]);
});
contract.on('PayoutExecuted', (event) => {
queryClient.invalidateQueries(['group', event.group_id]);
queryClient.invalidateQueries(['payout_history', event.group_id]);
queryClient.invalidateQueries(['member', event.recipient]);
});
contract.on('MemberJoined', (event) => {
queryClient.invalidateQueries(['members', event.group_id]);
queryClient.invalidateQueries(['group', event.group_id]);
});
contract.on('GroupCreated', (event) => {
queryClient.invalidateQueries(['groups']);
});
};// User-triggered refresh
const handleRefresh = async () => {
await queryClient.invalidateQueries();
toast.success('Data refreshed');
};
// Selective invalidation
const handleContributionSuccess = () => {
// Only invalidate affected queries
queryClient.invalidateQueries(['group', groupId]);
queryClient.invalidateQueries(['contribution_status', groupId]);
// Don't invalidate unrelated queries
// queryClient.invalidateQueries(['groups']); // ← skip this
};Measure cache effectiveness:
class CacheMetrics {
constructor() {
this.hits = 0;
this.misses = 0;
}
recordHit() {
this.hits++;
}
recordMiss() {
this.misses++;
}
getHitRate() {
const total = this.hits + this.misses;
return total > 0 ? (this.hits / total) * 100 : 0;
}
report() {
console.log(`Cache Hit Rate: ${this.getHitRate().toFixed(2)}%`);
console.log(`Hits: ${this.hits}, Misses: ${this.misses}`);
}
}
const cacheMetrics = new CacheMetrics();
// Track in cachedContractCall
const cached = contractCache.get(cacheKey);
if (cached) {
cacheMetrics.recordHit();
return cached;
} else {
cacheMetrics.recordMiss();
// ... fetch and cache
}
// Report periodically
setInterval(() => cacheMetrics.report(), 60000);Monitor gas consumption in tests:
#[test]
fn test_contribute_gas_budget() {
let env = Env::default();
env.budget().reset_unlimited();
// Setup
let contract = create_contract(&env);
let group_id = contract.create_group(1000, 30, 100);
let member = Address::random(&env);
contract.join_group(group_id, member.clone(), None);
// Measure
env.budget().reset_unlimited();
let start_cpu = env.budget().cpu_instruction_cost();
contract.contribute(group_id, member.clone(), 1000);
let end_cpu = env.budget().cpu_instruction_cost();
let gas_used = end_cpu - start_cpu;
println!("Contribute gas: {}", gas_used);
// Assert within budget
assert!(gas_used < 2_500_000, "Gas usage too high: {}", gas_used);
}Production gas monitoring:
// Log gas metrics in contract
pub fn contribute(env: &Env, group_id: u64, member: Address, amount: i128) {
let start = env.budget().cpu_instruction_cost();
// ... contribution logic ...
let end = env.budget().cpu_instruction_cost();
let gas_used = end - start;
// Log for monitoring
log!(&env, "contribute: group={}, gas={}", group_id, gas_used);
}Track gas trends over time:
# Run benchmarks and capture results
cargo test --manifest-path contracts/stellar-save/Cargo.toml benchmark -- --nocapture > gas_results.txt
# Extract and store metrics
grep "Gas used:" gas_results.txt | awk '{print $NF}' >> performance-results/gas-trends.jsonAnalyze storage usage:
pub fn get_storage_stats(env: &Env, group_id: u64) -> StorageStats {
let group_key = DataKey::Group(group_id);
let members_key = DataKey::Members(group_id);
// Count storage entries
let mut entry_count = 0u32;
let mut total_bytes = 0u64;
// Estimate from known structures
entry_count += 1; // group
total_bytes += 256; // Group struct
// Members list
let members = env.storage().persistent().get::<_, Vec<Address>>(&members_key);
if let Ok(members_vec) = members {
entry_count += 1;
total_bytes += (members_vec.len() as u64) * 32; // Address = 32 bytes
}
StorageStats {
total_entries: entry_count,
total_bytes,
cost_estimate: calculate_storage_cost(total_bytes)
}
}
fn calculate_storage_cost(bytes: u64) -> i128 {
// Stellar storage pricing: ~0.00001 XLM per byte per ledger
(bytes as i128) * 10_000 / 1_000_000_000
}Compare storage approaches:
#[test]
fn test_storage_comparison() {
let env = Env::default();
// Traditional approach: separate entries per member
let traditional_cost = {
let mut cost = 0u64;
for i in 0..100 {
env.storage().persistent().set(&format!("member_{}", i), &true);
cost += 1;
}
cost
};
// Optimized approach: bitmap
let optimized_cost = {
let bitmap = vec![true; 100];
env.storage().persistent().set(&"bitmap", &bitmap);
1u64
};
println!("Traditional: {} entries", traditional_cost);
println!("Optimized: {} entries", optimized_cost);
println!("Savings: {}%", ((traditional_cost - optimized_cost) * 100) / traditional_cost);
}Implement comprehensive monitoring:
import { getCLS, getFID, getFCP, getLCP, getTTFB, getINP } from 'web-vitals';
const sendMetricToAnalytics = (metric) => {
// Send to your analytics service
const body = JSON.stringify(metric);
// Use sendBeacon for reliability
if (navigator.sendBeacon) {
navigator.sendBeacon('/api/metrics', body);
} else {
fetch('/api/metrics', { method: 'POST', body });
}
// Also log locally
console.log(`${metric.name}: ${metric.value}ms`);
};
// Measure all Web Vitals
getCLS(sendMetricToAnalytics);
getFID(sendMetricToAnalytics);
getFCP(sendMetricToAnalytics);
getLCP(sendMetricToAnalytics);
getTTFB(sendMetricToAnalytics);
getINP(sendMetricToAnalytics);Track metrics over time:
// Store metrics in IndexedDB for historical analysis
const storeMetric = async (metric) => {
const db = await openDB('stellar-save-metrics');
const tx = db.transaction('metrics', 'readwrite');
await tx.store.add({
name: metric.name,
value: metric.value,
timestamp: Date.now(),
url: window.location.href
});
};
// Query historical data
const getMetricTrend = async (metricName, days = 7) => {
const db = await openDB('stellar-save-metrics');
const cutoff = Date.now() - (days * 24 * 60 * 60 * 1000);
const allMetrics = await db.getAll('metrics');
return allMetrics.filter(m =>
m.name === metricName && m.timestamp > cutoff
);
};Track contract call duration:
const measureContractCall = async (operation, fn) => {
const start = performance.now();
const startMark = `${operation}-start`;
const endMark = `${operation}-end`;
performance.mark(startMark);
try {
const result = await fn();
const duration = performance.now() - start;
performance.mark(endMark);
performance.measure(operation, startMark, endMark);
// Log metric
console.log(`${operation}: ${duration.toFixed(2)}ms`);
// Send to monitoring service
analytics.track('contract_call', {
operation,
duration: Math.round(duration),
success: true
});
return result;
} catch (error) {
const duration = performance.now() - start;
analytics.track('contract_call', {
operation,
duration: Math.round(duration),
success: false,
error: error.message
});
throw error;
}
};
// Usage
const group = await measureContractCall('get_group', () =>
contract.get_group(groupId)
);
const result = await measureContractCall('contribute', () =>
contract.contribute(groupId, amount)
);Measure component render time:
import { Profiler } from 'react';
const onRenderCallback = (
id, // Component name
phase, // "mount" or "update"
actualDuration,
baseDuration,
startTime,
commitTime
) => {
console.log(`${id} (${phase}): ${actualDuration.toFixed(2)}ms`);
// Alert if render is slow
if (actualDuration > 1000) {
console.warn(`Slow render detected: ${id} took ${actualDuration}ms`);
}
};
export const ProfiledGroupList = () => (
<Profiler id="GroupList" onRender={onRenderCallback}>
<GroupList />
</Profiler>
);Monitor RPC latency:
const monitorRPCLatency = async (rpcCall, operationName) => {
const start = Date.now();
try {
const result = await rpcCall();
const latency = Date.now() - start;
// Track latency
analytics.track('rpc_call', {
operation: operationName,
latency,
success: true
});
// Alert on slow calls
if (latency > 3000) {
console.warn(`Slow RPC call: ${operationName} took ${latency}ms`);
}
return result;
} catch (error) {
const latency = Date.now() - start;
analytics.track('rpc_call', {
operation: operationName,
latency,
success: false,
error: error.message
});
throw error;
}
};
// Usage
const group = await monitorRPCLatency(
() => contract.get_group(groupId),
'get_group'
);Track request queue depth:
class RequestMonitor {
constructor() {
this.activeRequests = 0;
this.maxConcurrent = 0;
}
async trackRequest(fn) {
this.activeRequests++;
this.maxConcurrent = Math.max(this.maxConcurrent, this.activeRequests);
try {
return await fn();
} finally {
this.activeRequests--;
}
}
getStats() {
return {
activeRequests: this.activeRequests,
maxConcurrent: this.maxConcurrent
};
}
}
const requestMonitor = new RequestMonitor();
// Usage
const result = await requestMonitor.trackRequest(() =>
contract.get_group(groupId)
);
// Report periodically
setInterval(() => {
const stats = requestMonitor.getStats();
console.log(`Active requests: ${stats.activeRequests}, Max: ${stats.maxConcurrent}`);
}, 10000);Run automated Lighthouse audits in CI/CD:
# Install
npm install -g @lhci/cli
# Run audit
lhci autorun --config .lighthouserc.jsonConfiguration:
{
"ci": {
"collect": {
"numberOfRuns": 3,
"url": ["http://localhost:4173"],
"staticDistDir": "./dist"
},
"upload": {
"target": "temporary-public-storage"
},
"assert": {
"preset": "lighthouse:recommended",
"assertions": {
"categories:performance": ["error", {"minScore": 0.85}],
"categories:accessibility": ["error", {"minScore": 0.90}],
"first-contentful-paint": ["warn", {"maxNumericValue": 2000}],
"largest-contentful-paint": ["warn", {"maxNumericValue": 2500}],
"cumulative-layout-shift": ["warn", {"maxNumericValue": 0.1}]
}
}
}
}Track metrics over time using automated dashboards:
Key metrics tracked:
- Gas costs per function (contract)
- Lighthouse scores (frontend)
- Web Vitals (frontend)
- Bundle sizes (frontend)
- API response times (frontend)
- Cache hit rates (frontend)
Dashboard setup:
# Generate performance report
./scripts/generate_performance_report.sh
# View in browser
open performance-report.htmlIntegrate Sentry for production monitoring:
import * as Sentry from "@sentry/react";
Sentry.init({
dsn: process.env.VITE_SENTRY_DSN,
environment: process.env.VITE_ENVIRONMENT,
tracesSampleRate: 0.1,
integrations: [
new Sentry.Replay({
maskAllText: true,
blockAllMedia: true
})
]
});
// Wrap components
export const App = Sentry.withProfiler(AppComponent);
// Track errors
try {
await contract.contribute(groupId, amount);
} catch (error) {
Sentry.captureException(error, {
tags: {
operation: 'contribute',
groupId
}
});
}Run all benchmarks:
cargo test --manifest-path contracts/stellar-save/Cargo.toml benchmark -- --nocaptureRun specific benchmark:
cargo test --manifest-path contracts/stellar-save/Cargo.toml benchmark_create_group_gas -- --nocaptureWith detailed output:
RUST_BACKTRACE=1 cargo test --manifest-path contracts/stellar-save/Cargo.toml benchmark -- --nocapture --test-threads=1Analyze storage usage:
# Run storage analysis
cargo test --manifest-path contracts/stellar-save/Cargo.toml test_storage_analysis -- --nocapture
# Compare traditional vs optimized
cargo test --manifest-path contracts/stellar-save/Cargo.toml test_storage_comparison -- --nocaptureExpected output:
Storage Analysis Report
========================
Members: 100
Cycles: 10
Traditional Approach: 1405 entries
Optimized Approach: 235 entries
Savings: 83%
Create custom benchmark:
#[test]
fn benchmark_custom_operation() {
let env = Env::default();
env.budget().reset_unlimited();
// Setup
let contract = create_contract(&env);
// Measure
let start = env.budget().cpu_instruction_cost();
contract.custom_operation();
let end = env.budget().cpu_instruction_cost();
let gas_used = end - start;
println!("Gas used: {}", gas_used);
assert!(gas_used < TARGET_GAS);
}Run locally:
# Build production bundle
cd frontend
npm run build
# Start preview server
npm run preview -- --host 127.0.0.1 --port 4173
# In another terminal, run Lighthouse
npx lighthouse http://127.0.0.1:4173 --output html --output-path ./lighthouse-report.htmlRun with CI configuration:
npx lhci autorun --config .lighthouserc-perf.jsonAnalyze bundle:
# Install analyzer
npm install -D rollup-plugin-visualizer
# Build with analysis
npm run build -- --mode production
# View report
open stats.htmlCheck bundle sizes:
# List all chunks
ls -lh dist/assets/
# Check total size
du -sh dist/Targets:
- Main bundle: < 200KB (gzipped)
- Vendor bundle: < 150KB (gzipped)
- Total initial load: < 350KB (gzipped)
Profile React components:
import { Profiler } from 'react';
<Profiler id="GroupList" onRender={onRenderCallback}>
<GroupList groups={groups} />
</Profiler>
function onRenderCallback(
id, phase, actualDuration, baseDuration, startTime, commitTime
) {
console.log(`${id} (${phase}) took ${actualDuration}ms`);
}Measure render time:
import { useEffect } from 'react';
useEffect(() => {
const start = performance.now();
return () => {
const duration = performance.now() - start;
console.log(`Component mounted for ${duration}ms`);
};
}, []);The project runs automated benchmarks on every PR and merge. See performance-benchmarking.md for details.
Workflow triggers:
- On pull request
- On push to main
- Weekly scheduled runs
Outputs:
- PR comments with results
- Performance dashboard
- Regression alerts
Run all benchmarks:
./scripts/run_benchmarks.shScript includes:
- Contract gas benchmarks
- Storage analysis
- Frontend Lighthouse audit
- Bundle size check
Thresholds:
- Gas increase > 10%: Warning
- Lighthouse score decrease > 5 points: Warning
- Bundle size increase > 20%: Warning
Response:
- Review changes causing regression
- Optimize if necessary
- Document intentional increases
# Full performance report
./scripts/generate_performance_report.sh
# Output: performance-report.mdReport includes:
- Gas costs for all functions
- Storage usage analysis
- Frontend metrics
- Historical trends
- Recommendations
# Compare current branch to main
./scripts/compare_performance.sh main
# Output: performance-comparison.mdPerformance data is stored in performance-results/ and tracked in Git for historical analysis.
View trends:
# Show gas cost trends
cat performance-results/gas-trends.json
# Show Lighthouse trends
cat performance-results/lighthouse-trends.jsonBefore submitting PR:
- Run gas benchmarks locally
- Check contract size (< 80KB warning, < 100KB limit)
- Run Lighthouse audit (scores > 85)
- Check bundle size (< 350KB initial load)
- Profile critical paths
- Review storage usage
- Test on slow network (throttled)
Code review focus:
- Unnecessary storage operations
- Inefficient loops
- Missing memoization
- Large bundle imports
- Unoptimized images
- Missing caching
Creating groups:
- Choose appropriate group size (< 100 recommended)
- Set reasonable cycle duration
- Consider gas costs in contribution amount
Contributing:
- Contribute early in cycle
- Monitor network congestion
- Use recommended gas limits
Monitoring:
- Check group performance metrics
- Review transaction costs
- Report performance issues
- Storage Optimization Guide - Detailed storage optimization strategies
- Size Optimization Guide - Contract size reduction techniques
- Performance Benchmarking - Automated benchmarking pipeline
- Performance Config - Threshold configurations
- Architecture Documentation - System architecture overview
Performance issues?
- Check GitHub Issues for known issues
- Review FAQ for common questions
- Join Discussions for community help
Found a performance bug?
- Open an issue with benchmark results
- Include reproduction steps
- Provide profiling data if available
Stellar-Save enforces a per-chunk size limit of 100 KB (uncompressed) via Vite's chunkSizeWarningLimit. Breaching this limit emits a build warning and will fail CI once the bundle-size gate is added. The goal is to keep the initial load under 200 KB gzipped across all entry-point chunks.
cd frontend
npm run build:analyzeThis runs a production build and then opens frontend/dist/stats.html in your browser. The visualizer shows a treemap of every module included in the bundle, colour-coded by chunk.
| Colour / label | Meaning |
|---|---|
vendor-react |
React, ReactDOM, React Router — should be ≈ 50 KB gz |
vendor-mui |
MUI core + Emotion — largest vendor chunk, ≈ 100–130 KB gz |
vendor-stellar |
Stellar SDK + Freighter API — ≈ 80–100 KB gz |
vendor-i18n |
i18next + react-i18next — ≈ 15 KB gz |
route-analytics |
Analytics, platform analytics, group analytics pages |
route-admin |
Admin feedback dashboard page |
route-charts |
Recharts library — only loaded with chart-heavy routes |
index (entry) |
App shell, router, layout, shared hooks |
Red flags to look for:
- The
indexentry chunk grows unexpectedly — a new import inApp.tsxorAppRouter.tsxmay have pulled in a heavy module synchronously. - A page module appears in the
indexchunk instead of its ownroute-*chunk — check that the import inroutes.tsxuseslazy(). rechartsappears outsideroute-charts— a page outside the analytics routes may be importing chart components directly.
All routes are registered through src/routing/routes.tsx using React.lazy. Never import a page component directly from AppRouter.tsx or a shared module — this defeats code splitting.
// ✅ Correct — creates a separate chunk
const AnalyticsDashboardPage = lazy(() => import('../pages/AnalyticsDashboardPage'));
// ❌ Wrong — page lands in the initial bundle
import AnalyticsDashboardPage from '../pages/AnalyticsDashboardPage';Suspense fallbacks in AppRouter.tsx show a skeleton layout (not a blank screen) while the chunk downloads.
| Chunk | Budget (uncompressed) | Action on breach |
|---|---|---|
index (entry) |
200 KB | Investigate new synchronous imports |
Any vendor-* chunk |
200 KB | Review whether the full library is needed |
Any route-* chunk |
150 KB | Check for unintended transitive imports |
| Individual page chunk | 50 KB | Lazy-load heavy sub-components |
Run npm run build locally before opening a PR that adds new dependencies. Check the Vite output for (!) Some chunks are larger than 100 kB warnings.
Last Updated: June 2026
Maintained by: Stellar-Save Contributors