Best practices for optimizing performance across the Brain-Storm stack — backend caching, frontend rendering, database queries, Soroban contract gas, and CDN/asset delivery.
- Backend Caching Strategies (Redis)
- Frontend Optimization (Next.js SSR/SSG)
- Database Query Optimization
- Contract Gas Optimization
- CDN and Asset Optimization
Brain-Storm uses cache-manager with cache-manager-redis-store as a global NestJS cache module. Redis also backs the rate-limiter via ThrottlerStorageRedisService.
The default cache TTL is 60 seconds, set in AppModule:
CacheModule.registerAsync({
useFactory: (config: ConfigService) => ({
store: redisStore,
url: config.get<string>('redis.url'),
ttl: 60, // seconds — global default
}),
});Override per call by passing an explicit TTL (in milliseconds) to cacheManager.set().
All service-level caching follows the cache-aside pattern: check cache → on miss, fetch from source → populate cache.
const cached = await this.cacheManager.get<T>(cacheKey);
if (cached) return cached;
const data = await this.repo.find(/* ... */);
await this.cacheManager.set(cacheKey, data, ttlMs);
return data;| Data | Cache Key | TTL |
|---|---|---|
| All courses list | courses:all |
60 s (global default) |
| Leaderboard top 50 | leaderboard:top50 |
300 s (5 min) |
| BST token balance | token_balance:<publicKey> |
30 s |
Mutating operations (create, update, delete) must invalidate the relevant cache key immediately:
// courses.service.ts
private async invalidateCache() {
await this.cacheManager.del('courses:all');
}
async create(data: Partial<Course>) {
const course = await this.repo.save(this.repo.create(data));
await this.invalidateCache(); // keep cache consistent
return course;
}The global throttler uses Redis as its backing store, ensuring rate-limit counters are shared across all backend instances (important in multi-replica deployments):
ThrottlerModule.forRootAsync({
useFactory: (config: ConfigService) => ({
throttlers: [{ ttl: 60_000, limit: 100 }],
storage: new ThrottlerStorageRedisService(config.get('redis.url')),
}),
});Sensitive endpoints (e.g. credential minting) apply a tighter per-route limit:
@Throttle({ default: { limit: 3, ttl: 60_000 } })
@Post('mint')
mintCredential() { /* ... */ }- Keep Redis co-located with the backend (same VPC/subnet) to minimize network latency.
- Use a dedicated Redis instance for caching vs. one for sessions/rate-limiting if traffic is high.
- Monitor cache hit rates with
redis-cli info stats(keyspace_hits/keyspace_misses). - Avoid caching user-specific data under shared keys — always include the user ID or public key in the cache key.
The frontend uses Next.js 14 App Router with output: 'standalone' for lean Docker images.
| Page type | Strategy | When to use |
|---|---|---|
| Course catalogue | SSG + ISR | Content changes infrequently |
| Course detail | SSG + ISR | Per-course, low mutation rate |
| User dashboard | SSR | Personalized, auth-gated |
| Leaderboard | SSR or client fetch | Near-real-time data |
Static Generation with revalidation (ISR):
// app/courses/page.tsx
export const revalidate = 60; // regenerate at most every 60 s
export default async function CoursesPage() {
const courses = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/v1/courses`, {
next: { revalidate: 60 },
}).then(r => r.json());
return <CourseList courses={courses} />;
}Server-side rendering for auth-gated pages:
// app/dashboard/page.tsx
export const dynamic = 'force-dynamic';
export default async function DashboardPage() {
const data = await fetchWithAuth('/v1/users/me');
return <Dashboard data={data} />;
}Next.js <Image> automatically serves WebP/AVIF, resizes on demand, and lazy-loads. Always prefer it over <img>:
import Image from 'next/image';
<Image
src={course.thumbnailUrl}
alt={course.title}
width={640}
height={360}
priority={isAboveFold} // set true for hero images only
/>Remote image domains are whitelisted in next.config.js. Avoid the catch-all hostname: '**' in production — restrict to your actual CDN domain.
- Use dynamic imports for heavy components (e.g. Stellar SDK, wallet UI):
import dynamic from 'next/dynamic';
const WalletConnect = dynamic(() => import('@/components/WalletConnect'), {
ssr: false, // wallet APIs are browser-only
});- Analyze bundle size with:
ANALYZE=true npm run build:frontendoutput: 'standalone' in next.config.js produces a minimal Docker image by tracing only the files actually used. Keep this enabled for production deployments.
Brain-Storm uses TypeORM with PostgreSQL. The following patterns are already in use and should be maintained.
All list endpoints accept page and limit parameters. Always paginate — never load unbounded result sets:
const { page = 1, limit = 20 } = query;
const offset = (page - 1) * limit;
qb.skip(offset).take(limit);Use createQueryBuilder when you need joins, aggregates, or conditional filters. Avoid N+1 queries by joining relations in a single query:
// Good — single query with join + aggregate
const { raw, entities } = await qb
.leftJoin('course.reviews', 'review')
.addSelect('COALESCE(AVG(review.rating), 0)', 'course_averageRating')
.groupBy('course.id')
.getRawAndEntities();
// Bad — N+1: one query per course to fetch reviews
const courses = await this.repo.find({ relations: ['reviews'] });
courses.map(c => average(c.reviews));Add indexes on columns used in WHERE, ORDER BY, and JOIN conditions. TypeORM decorators:
import { Entity, Column, Index } from 'typeorm';
@Entity()
@Index(['isPublished', 'isDeleted']) // composite index for the common filter
export class Course {
@Column() isPublished: boolean;
@Column() isDeleted: boolean;
@Index()
@Column() createdAt: Date;
}Key columns to index in Brain-Storm:
| Table | Column(s) | Reason |
|---|---|---|
course |
is_published, is_deleted |
Every list query filters on both |
course |
created_at |
Default sort column |
user |
stellar_public_key |
Leaderboard + balance lookups |
progress |
user_id, course_id |
Progress lookups by student + course |
Avoid SELECT * on wide tables. Use .select() to limit columns:
qb.select(['course.id', 'course.title', 'course.level', 'course.createdAt']);TypeORM uses a connection pool by default. Tune it for your workload in AppModule:
TypeOrmModule.forRootAsync({
useFactory: (config) => ({
// ...
extra: {
max: 20, // max pool size (default 10)
idleTimeoutMillis: 30_000,
},
}),
});Soroban charges fees based on CPU instructions, memory, and ledger entry reads/writes. The following patterns are used in Brain-Storm contracts and should be followed for any new contract work.
instance storage (e.g. Admin, TotalSupply) is loaded once per transaction invocation. Prefer it over persistent for values read on every call:
// Cheap — loaded with the contract instance
env.storage().instance().get(&DataKey::Admin)
// More expensive — separate ledger entry read
env.storage().persistent().get(&DataKey::Progress(addr, course))Persistent entries that expire are archived and cost more to restore. Extend TTL when entries are accessed:
const TTL_THRESHOLD: u32 = 100; // extend if fewer than 100 ledgers remain
const TTL_EXTEND_TO: u32 = 500; // extend to 500 ledgers (~42 hours on mainnet)
env.storage().persistent().extend_ttl(&key, TTL_THRESHOLD, TTL_EXTEND_TO);This pattern is already used in the Analytics contract. Apply it consistently in any new persistent storage writes.
Each set() on persistent storage costs more than a read. Batch updates where possible and avoid writing unchanged values:
// Only write if value actually changed
let current: u32 = env.storage().persistent().get(&key).unwrap_or(0);
if current != new_value {
env.storage().persistent().set(&key, &new_value);
}Short symbols (≤ 9 chars) are encoded inline and cheaper than heap-allocated strings:
// Good
const TRANSFER: Symbol = symbol_short!("transfer");
// Avoid for frequently-used keys
let key = Symbol::new(&env, "transfer_event_key");require_auth() adds CPU cost. Only call it on the addresses that actually need to authorize the operation — not on read-only functions:
// Only admin-mutating functions need this
pub fn set_admin(env: Env, new_admin: Address) {
let admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap();
admin.require_auth(); // necessary
// ...
}
pub fn get_progress(env: Env, student: Address, course_id: Symbol) -> ProgressRecord {
// No require_auth — read-only, anyone can query
}Always simulate transactions before submitting to catch errors cheaply and get an accurate fee estimate:
const simResult = await sorobanServer.simulateTransaction(tx);
if (SorobanRpc.Api.isSimulationError(simResult)) {
throw new Error(simResult.error);
}
// simResult.minResourceFee gives the minimum fee
const prepared = await sorobanServer.prepareTransaction(tx);Next.js serves files in apps/frontend/public/ at the root path. For production, put a CDN (e.g. CloudFront) in front of the Next.js origin and cache static assets aggressively.
Recommended CloudFront cache behaviors:
| Path pattern | Cache TTL | Notes |
|---|---|---|
/_next/static/* |
1 year | Content-hashed filenames — safe to cache forever |
/images/* |
7 days | Versioned via query string if needed |
/api/* |
No cache | Dynamic — bypass CDN |
/* (default) |
60 s | HTML pages — short TTL for ISR compatibility |
Set long-lived headers for hashed assets. Next.js does this automatically for /_next/static/. For custom static files, add headers in next.config.js:
async headers() {
return [
{
source: '/fonts/:path*',
headers: [{ key: 'Cache-Control', value: 'public, max-age=31536000, immutable' }],
},
];
}- Use Next.js
<Image>— it serves WebP/AVIF and generates srcsets automatically. - For user-uploaded content (e.g. course thumbnails), store originals in S3 and serve via CloudFront. Pass the CloudFront domain as a whitelisted
remotePatterninnext.config.js. - Remove the catch-all
hostname: '**'pattern in production and replace with your specific CDN hostname.
Use next/font to self-host fonts — it eliminates the external font request and inlines the font-face declaration:
import { Inter } from 'next/font/google';
const inter = Inter({ subsets: ['latin'], display: 'swap' });Ensure gzip/Brotli compression is enabled at the reverse proxy or CDN layer. Next.js standalone mode does not enable compression by default — configure it in Nginx or your load balancer:
gzip on;
gzip_types text/plain text/css application/javascript application/json;
gzip_min_length 1024;| Metric | Target | Tool |
|---|---|---|
| API p95 latency | < 500 ms | Prometheus + Grafana |
| Database query p95 | < 100 ms | PostgreSQL slow query log |
| Cache hit rate | > 80% | Redis INFO stats |
| Error rate | < 1% | Application logs |
| Stellar RPC latency | < 2 s | Health check endpoint |
The backend exposes metrics at /metrics:
curl http://localhost:3000/metrics | grep http_request_duration_secondsKey metrics:
http_request_duration_seconds— API response time histogramhttp_requests_total— Total requests by endpoint and statusdb_query_duration_seconds— Database query latencycache_hits_total/cache_misses_total— Cache effectiveness
Pre-built dashboards in infra/monitoring/grafana/dashboards/:
- API Performance: Request latency, error rate, throughput
- Database: Query latency, connection pool usage, table sizes
- Cache: Hit rate, evictions, memory usage
- Stellar: RPC latency, transaction success rate
Configure alerts in Prometheus for:
- API p95 latency > 1000 ms
- Error rate > 5%
- Cache hit rate < 50%
- Database connections > 80% of pool size
Before deploying to production:
- All API endpoints have cache TTLs configured
- Database queries use indexes on
WHEREandJOINcolumns - Frontend images use Next.js
<Image>component - Static assets have long-lived cache headers
- Contract functions minimize ledger entry writes
- Load tests pass with p95 < 500 ms
- Cache hit rate > 80% on leaderboard endpoint
- No N+1 queries in critical paths
- Pagination is enforced on all list endpoints
- Slow query log is monitored
- Check Prometheus dashboard for latency trends.
- Identify the slow endpoint via
http_request_duration_secondshistogram. - Check database slow query log:
SELECT query, mean_time, calls FROM pg_stat_statements ORDER BY mean_time DESC LIMIT 10;
- Add indexes on
WHEREandJOINcolumns if missing. - Check Redis connectivity — if down, cache misses cascade to Stellar RPC calls.
- Check Node.js heap size:
node --max-old-space-size=2048 dist/main.js
- Profile with
clinic.js:npm install -g clinic clinic doctor -- node dist/main.js
- Look for memory leaks in event listeners or unclosed connections.
SELECT count(*) FROM pg_stat_activity;If approaching max pool size:
- Increase pool size in TypeORM config.
- Check for long-running queries blocking connections.
- Enable connection pooling via PgBouncer.
Check /v1/health endpoint:
curl http://localhost:3000/v1/health | jq .sorobanIf "down", check status.stellar.org or switch to a different RPC endpoint in .env.
The following optimizations were applied to reduce JS payload and improve Core Web Vitals:
| Optimization | Impact |
|---|---|
Dynamic import of CourseCreationWizard (uses @dnd-kit) |
Reduces main bundle by ~25 KB gzip; loads only on /instructor/courses/new |
| Lazy-loaded socket.io in Student Dashboard | Defers ~30 KB WebSocket client until socket is needed |
Dynamic import of @stellar/freighter-api |
Already lazy-loaded in WalletSection; kept pattern |
optimizePackageImports in next.config.js |
Enables barrel-export tree-shaking for zustand, next-intl, react-hook-form, zod |
removeConsole in production |
Strips console.* calls from production bundle |
Removed catch-all hostname: '**' image pattern |
Replaced with explicit domains (lh3.googleusercontent.com, gravatar.com, images.unsplash.com) |
- Next.js
<Image>now configured with explicitdeviceSizesandimageSizesfor optimal srcset generation - AVIF + WebP formats enabled by default via
formats: ['image/avif', 'image/webp'] - Course cards use
sizesprop for responsive image loading - All remote images restricted to specific CDN domains (removed insecure
**catch-all)
/_next/static/*files:max-age=31536000, immutable(1 year)/fonts/*files:max-age=31536000, immutable(1 year)- Fonts self-hosted via
next/fonteliminating external font requests
A Lighthouse CI config (lighthouserc.js) enforces the following budgets:
| Metric | Budget |
|---|---|
| Largest Contentful Paint (LCP) | < 2500 ms |
| Cumulative Layout Shift (CLS) | < 0.1 |
| Total Blocking Time (TBT) | < 200 ms |
| Interaction to Next Paint (INP) | < 200 ms |
| First Contentful Paint (FCP) | < 1800 ms |
| Speed Index | < 3000 ms |
| Total Bundle Size | < 500 KB |
| Unused JavaScript | < 100 KB |
# Run bundle analysis
npm run analyze
# Run Lighthouse CI locally
npx lhci autorun
# Check production build size
npm run build
# Look at the .next/analyze/ output for detailed bundle breakdownsAdd the following step to your CI pipeline (e.g., .github/workflows):
- name: Run Lighthouse CI
run: |
npm install -g @lhci/cli
npm run build
lhci autorun
env:
LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }}- development-setup.md — Local environment setup
- contracts.md — Soroban contract reference
- monitoring-observability.md — Detailed monitoring guide
- load-testing-guide.md — Load testing procedures