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.
- Gas Optimization Strategies
- Frontend Performance Tips
- Caching Best Practices
- Performance Monitoring
- Benchmarking 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
Gas Cost Targets:
| Function | Target Gas | Critical |
|---|---|---|
create_group |
< 2M | No |
contribute |
< 1.5M | No |
auto_advance_cycle |
< 3M | Yes |
distribute_winnings |
< 4M | Yes |
query_group_status |
< 500K | No |
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
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: ~2M gas
- Each member joining: ~500K gas
- Each contribution: ~1.5M gas
- Payout distribution: ~4M gas
Timing strategies:
- Contribute early in the cycle to avoid rush
- Batch operations when possible
- Monitor network congestion and gas prices
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 | Warning |
|---|---|---|
| First Contentful Paint (FCP) | < 1.8s | < 2.5s |
| Largest Contentful Paint (LCP) | < 2.5s | < 4.0s |
| Cumulative Layout Shift (CLS) | < 0.1 | < 0.25 |
| First Input Delay (FID) | < 100ms | < 300ms |
| Interaction to Next Paint (INP) | < 200ms | < 500ms |
React Query configuration:
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
}
}
});Cache invalidation:
// Invalidate after mutation
const mutation = useMutation({
mutationFn: contributeToGroup,
onSuccess: () => {
queryClient.invalidateQueries(['groups']);
queryClient.invalidateQueries(['group', groupId]);
}
});LocalStorage for persistent data:
// Cache user preferences
const cacheUserPreferences = (prefs) => {
localStorage.setItem('user_prefs', JSON.stringify(prefs));
};
// Cache with expiration
const cacheWithExpiry = (key, data, ttl) => {
const item = {
value: data,
expiry: Date.now() + ttl
};
localStorage.setItem(key, JSON.stringify(item));
};SessionStorage for temporary data:
// Cache for current session only
sessionStorage.setItem('temp_group_data', JSON.stringify(groupData));Cache static assets:
// service-worker.js
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open('stellar-save-v1').then((cache) => {
return cache.addAll([
'/',
'/index.html',
'/styles.css',
'/app.js'
]);
})
);
});Cache transaction history:
const fetchTransactionHistory = async (address) => {
const cacheKey = `tx_history_${address}`;
const cached = sessionStorage.getItem(cacheKey);
if (cached) {
const { data, timestamp } = JSON.parse(cached);
if (Date.now() - timestamp < 60000) { // 1 minute
return data;
}
}
const data = await horizonServer.transactions()
.forAccount(address)
.limit(50)
.call();
sessionStorage.setItem(cacheKey, JSON.stringify({
data,
timestamp: Date.now()
}));
return data;
};Cache contract state:
const cachedContractCall = async (contractId, method, params) => {
const cacheKey = `${contractId}_${method}_${JSON.stringify(params)}`;
// Check cache first
const cached = cache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < 30000) {
return cached.data;
}
// Make RPC call
const data = await contract[method](...params);
// Update cache
cache.set(cacheKey, {
data,
timestamp: Date.now()
});
return data;
};Time-based:
// Invalidate after fixed duration
const TTL = {
GROUP_DATA: 30000, // 30 seconds
USER_PROFILE: 300000, // 5 minutes
STATIC_DATA: 3600000 // 1 hour
};Event-based:
// Invalidate on Soroban events
contract.on('ContributionMade', (event) => {
queryClient.invalidateQueries(['group', event.group_id]);
});
contract.on('PayoutExecuted', (event) => {
queryClient.invalidateQueries(['group', event.group_id]);
queryClient.invalidateQueries(['member', event.recipient]);
});Manual invalidation:
// User-triggered refresh
const handleRefresh = () => {
queryClient.invalidateQueries();
toast.success('Data refreshed');
};Monitor gas consumption:
// In tests
#[test]
fn test_contribute_gas() {
let env = Env::default();
env.budget().reset_unlimited();
// Execute operation
contract.contribute(&group_id, &member, &amount);
// Check gas usage
let gas_used = env.budget().cpu_instruction_cost();
assert!(gas_used < 1_500_000, "Gas usage too high: {}", gas_used);
}Log gas metrics:
// Production monitoring
log!(&env, "contribute gas: {}", env.budget().cpu_instruction_cost());Monitor storage growth:
pub fn get_storage_stats(env: &Env, group_id: u64) -> StorageStats {
StorageStats {
total_entries: count_storage_entries(env, group_id),
total_bytes: estimate_storage_bytes(env, group_id),
cost_estimate: calculate_storage_cost(env, group_id)
}
}Implement monitoring:
import { getCLS, getFID, getFCP, getLCP, getTTFB } from 'web-vitals';
const sendToAnalytics = (metric) => {
// Send to your analytics service
console.log(metric);
};
getCLS(sendToAnalytics);
getFID(sendToAnalytics);
getFCP(sendToAnalytics);
getLCP(sendToAnalytics);
getTTFB(sendToAnalytics);Track contract call duration:
const measureContractCall = async (operation, fn) => {
const start = performance.now();
try {
const result = await fn();
const duration = performance.now() - start;
// Log metric
console.log(`${operation}: ${duration}ms`);
// Send to monitoring service
analytics.track('contract_call', {
operation,
duration,
success: true
});
return result;
} catch (error) {
const duration = performance.now() - start;
analytics.track('contract_call', {
operation,
duration,
success: false,
error: error.message
});
throw error;
}
};
// Usage
const result = await measureContractCall('contribute', () =>
contract.contribute(groupId, amount)
);Monitor RPC latency:
const monitorRPCLatency = async (rpcCall) => {
const start = Date.now();
const result = await rpcCall();
const latency = Date.now() - start;
if (latency > 3000) {
console.warn(`Slow RPC call: ${latency}ms`);
}
return result;
};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"]
},
"assert": {
"assertions": {
"categories:performance": ["error", {"minScore": 0.85}],
"categories:accessibility": ["error", {"minScore": 0.90}]
}
}
}
}Track metrics over time using the automated dashboard (see performance-benchmarking.md).
Key metrics tracked:
- Gas costs per function
- Lighthouse scores
- Web Vitals
- Bundle sizes
- API response times
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
Last Updated: April 2026
Maintained by: Stellar-Save Contributors