# Performance Optimization This guide covers performance tuning and optimization strategies for Changemaker Lite V2. ## Overview ### Performance Areas 1. **Database** - Query optimization, indexing, connection pooling 2. **API** - Caching, rate limiting, pagination 3. **Frontend** - Code splitting, lazy loading, bundling 4. **Docker** - Resource limits, multi-stage builds 5. **Nginx** - Compression, caching, keep-alive 6. **Email Queue** - Worker count, batch processing 7. **Monitoring** - Prometheus metrics, Grafana dashboards ### Performance Metrics **Target performance:** - API response time: < 200ms (p95) - Database query time: < 50ms (p95) - Frontend load time: < 2s (initial) - Email sending: 100+ emails/minute - Concurrent users: 500+ --- ## Database Optimization ### Index Optimization **Find missing indexes:** ```sql -- Find tables without indexes SELECT schemaname, tablename, indexname FROM pg_indexes WHERE schemaname = 'public' ORDER BY tablename; -- Find columns used in WHERE but not indexed SELECT * FROM pg_stat_user_tables WHERE schemaname = 'public' AND seq_scan > 1000 AND seq_tup_read / seq_scan > 10000 ORDER BY seq_scan DESC; ``` **Add indexes to frequently queried columns:** ```prisma model Location { id String @id @default(uuid()) address String city String province String postalCode String createdAt DateTime @default(now()) // Add indexes for WHERE clauses @@index([postalCode]) // WHERE postalCode = '...' @@index([city]) // WHERE city = '...' @@index([province]) // WHERE province = '...' @@index([createdAt]) // ORDER BY createdAt // Composite index for multi-column queries @@index([province, city]) // WHERE province = '...' AND city = '...' } ``` **Create migration:** ```bash docker compose exec api npx prisma migrate dev --name add_location_indexes ``` **Verify index usage:** ```sql EXPLAIN ANALYZE SELECT * FROM "Location" WHERE "postalCode" = 'M5H 2N2'; -- Should show: -- Index Scan using Location_postalCode_idx -- NOT: Seq Scan on "Location" ``` --- ### Query Optimization **Use select instead of fetching all fields:** ```typescript // Bad - fetches all fields const users = await prisma.user.findMany(); // Returns: id, email, password, name, role, createdAt, updatedAt, ... // Good - only needed fields const users = await prisma.user.findMany({ select: { id: true, email: true, name: true, role: true } }); ``` **Use include instead of separate queries:** ```typescript // Bad - N+1 queries const campaigns = await prisma.campaign.findMany(); for (const campaign of campaigns) { const emails = await prisma.campaignEmail.findMany({ where: { campaignId: campaign.id } }); campaign.emails = emails; } // Good - single query with join const campaigns = await prisma.campaign.findMany({ include: { emails: true } }); ``` **Paginate large result sets:** ```typescript // Bad - fetch all const locations = await prisma.location.findMany(); // Returns 10,000+ rows // Good - paginate const locations = await prisma.location.findMany({ take: 50, // Limit skip: page * 50, // Offset orderBy: { createdAt: 'desc' } }); ``` **Use aggregations efficiently:** ```typescript // Bad - count all then filter const allUsers = await prisma.user.findMany(); const activeCount = allUsers.filter(u => u.role !== 'TEMP').length; // Good - count in database const activeCount = await prisma.user.count({ where: { role: { not: 'TEMP' } } }); ``` --- ### Connection Pooling **Configure pool size:** ```bash # In .env DATABASE_URL="postgresql://changemaker:password@v2-postgres:5432/changemaker_v2?connection_limit=20&pool_timeout=30" # connection_limit: Max connections (default: 10) # pool_timeout: Max wait time in seconds (default: 10) ``` **Recommended pool sizes:** - Development: 5-10 connections - Production (1 API instance): 10-20 connections - Production (3 API instances): 5-10 per instance **Formula:** ``` Total connections = (API instances × pool size) + overhead Overhead = Prisma Studio (1) + other clients (5) Example: 3 instances × 10 pool + 6 overhead = 36 connections Set PostgreSQL max_connections = 50 (1.4× usage) ``` **Monitor pool usage:** ```sql -- View active connections SELECT count(*), state FROM pg_stat_activity WHERE datname = 'changemaker_v2' GROUP BY state; -- Alert if nearing limit SELECT count(*) FROM pg_stat_activity WHERE datname = 'changemaker_v2'; -- If > 80% of max_connections, increase limit or reduce pool size ``` --- ### Read Replicas For read-heavy workloads, add read replicas: ```yaml # docker-compose.yml v2-postgres-read: image: postgres:16-alpine environment: POSTGRES_DB: changemaker_v2 POSTGRES_USER: changemaker POSTGRES_PASSWORD: ${V2_POSTGRES_PASSWORD} command: postgres -c wal_level=replica -c max_wal_senders=3 ``` Configure replication in Prisma: ```typescript // Use read replica for read queries const readPrisma = new PrismaClient({ datasources: { db: { url: process.env.READ_DATABASE_URL } } }); // Read from replica const users = await readPrisma.user.findMany(); // Write to primary const user = await prisma.user.create({ data: { ... } }); ``` --- ## API Optimization ### Caching Strategies **Redis caching:** ```typescript // Cache expensive operations import { redis } from './config/redis'; export const getCampaigns = async () => { // Check cache const cacheKey = 'campaigns:all'; const cached = await redis.get(cacheKey); if (cached) { return JSON.parse(cached); } // Query database const campaigns = await prisma.campaign.findMany({ include: { emails: true } }); // Cache for 5 minutes await redis.setex(cacheKey, 300, JSON.stringify(campaigns)); return campaigns; }; ``` **Invalidate cache on updates:** ```typescript export const updateCampaign = async (id: string, data: any) => { // Update database const campaign = await prisma.campaign.update({ where: { id }, data }); // Invalidate cache await redis.del('campaigns:all'); await redis.del(`campaign:${id}`); return campaign; }; ``` **Cache patterns:** - **Cache-aside:** Check cache, fetch from DB if miss - **Write-through:** Update DB and cache simultaneously - **Write-behind:** Update cache, async update DB - **TTL:** Set expiration time (5min-1hour typical) --- ### Rate Limiting **Configure rate limits:** ```typescript // In api/src/middleware/rate-limit.ts import rateLimit from 'express-rate-limit'; // General API export const apiRateLimit = rateLimit({ windowMs: 60 * 1000, // 1 minute max: 100, // 100 requests per minute standardHeaders: true, legacyHeaders: false, }); // Auth endpoints (stricter) export const authRateLimit = rateLimit({ windowMs: 60 * 1000, max: 10, // 10 requests per minute message: 'Too many login attempts. Please try again later.' }); // Public endpoints (more lenient) export const publicRateLimit = rateLimit({ windowMs: 60 * 1000, max: 200 // 200 requests per minute }); ``` **Apply to routes:** ```typescript // In server.ts app.use('/api/auth', authRateLimit); app.use('/api', apiRateLimit); app.use('/public', publicRateLimit); ``` --- ### Pagination **Implement cursor-based pagination:** ```typescript // api/src/modules/users/users.controller.ts export const getUsers = async (req: Request, res: Response) => { const { cursor, limit = 50 } = req.query; const users = await prisma.user.findMany({ take: Number(limit) + 1, // Fetch one extra to check if more skip: cursor ? 1 : 0, cursor: cursor ? { id: cursor as string } : undefined, orderBy: { createdAt: 'desc' } }); const hasMore = users.length > Number(limit); if (hasMore) users.pop(); // Remove extra res.json({ data: users, cursor: hasMore ? users[users.length - 1].id : null, hasMore }); }; ``` **Frontend pagination:** ```typescript // admin/src/pages/UsersPage.tsx const [users, setUsers] = useState([]); const [cursor, setCursor] = useState(null); const [hasMore, setHasMore] = useState(true); const loadMore = async () => { const response = await api.get('/api/users', { params: { cursor, limit: 50 } }); setUsers([...users, ...response.data.data]); setCursor(response.data.cursor); setHasMore(response.data.hasMore); }; ``` --- ### Response Compression Enable gzip compression: ```typescript // In server.ts import compression from 'compression'; app.use(compression({ level: 6, // Compression level (0-9) threshold: 1024 // Only compress responses > 1KB })); ``` --- ## Frontend Optimization ### Code Splitting **Route-based splitting:** ```typescript // admin/src/App.tsx import { lazy, Suspense } from 'react'; // Lazy load pages const UsersPage = lazy(() => import('./pages/UsersPage')); const CampaignsPage = lazy(() => import('./pages/CampaignsPage')); const LocationsPage = lazy(() => import('./pages/LocationsPage')); function App() { return ( }> } /> } /> } /> ); } ``` **Component splitting:** ```typescript // Lazy load heavy components const MapView = lazy(() => import('./components/MapView')); function Page() { return ( }> ); } ``` --- ### Lazy Loading **Images:** ```typescript Description ``` **Large libraries:** ```typescript // Don't import large libs at top level import dayjs from 'dayjs'; // ❌ Always loads // Import only when needed const formatDate = async (date: Date) => { const dayjs = (await import('dayjs')).default; // ✅ Loads on demand return dayjs(date).format('YYYY-MM-DD'); }; ``` --- ### Bundle Optimization **Analyze bundle size:** ```bash cd admin npm run build npx vite-bundle-visualizer ``` **Tree shaking:** ```typescript // Import only what you need import { Button } from 'antd'; // ❌ Imports all of antd import Button from 'antd/es/button'; // ✅ Only button ``` **Configure Vite:** ```typescript // admin/vite.config.ts export default defineConfig({ build: { rollupOptions: { output: { manualChunks: { vendor: ['react', 'react-dom', 'react-router-dom'], antd: ['antd'], maps: ['leaflet', 'react-leaflet'] } } }, chunkSizeWarningLimit: 1000 } }); ``` --- ### Memoization **React.memo for expensive components:** ```typescript import { memo } from 'react'; const LocationMarker = memo(({ location }) => { return ( ); }, (prev, next) => { // Only re-render if location changed return prev.location.id === next.location.id; }); ``` **useMemo for expensive calculations:** ```typescript import { useMemo } from 'react'; function MapView({ locations }) { // Only recalculate when locations change const bounds = useMemo(() => { if (!locations.length) return null; const coords = locations.map(l => [l.latitude, l.longitude]); return L.latLngBounds(coords); }, [locations]); return ; } ``` **useCallback for stable functions:** ```typescript import { useCallback } from 'react'; function Table({ data }) { // Stable reference for row click handler const handleRowClick = useCallback((row) => { console.log('Clicked:', row.id); }, []); return ; } ``` --- ## Docker Optimization ### Resource Limits ```yaml # docker-compose.yml api: deploy: resources: limits: cpus: '2.0' # Max 2 CPU cores memory: 4G # Max 4GB RAM reservations: cpus: '0.5' # Reserve 0.5 cores memory: 1G # Reserve 1GB ``` **Monitor resource usage:** ```bash docker stats # Shows: # CONTAINER CPU % MEM USAGE / LIMIT MEM % # api 15% 1.2GB / 4GB 30% ``` --- ### Multi-Stage Builds **Optimize Dockerfile:** ```dockerfile # Build stage FROM node:20-alpine AS builder WORKDIR /app COPY package*.json ./ RUN npm ci --only=production COPY . . RUN npm run build # Runtime stage (smaller) FROM node:20-alpine WORKDIR /app COPY --from=builder /app/dist ./dist COPY --from=builder /app/node_modules ./node_modules COPY package*.json ./ CMD ["node", "dist/server.js"] ``` **Benefits:** - Smaller final image (no build tools) - Faster deployment - Better security (fewer packages) --- ### Volume Performance **Use cached volumes for dependencies:** ```yaml api: volumes: - ./api:/app - /app/node_modules # Don't bind-mount node_modules - api-build:/app/dist:cached # Cache build output ``` **For macOS/Windows:** ```yaml api: volumes: - ./api:/app:cached # Cached mode for better performance ``` --- ## Nginx Optimization ### Gzip Compression ```nginx # nginx/nginx.conf http { gzip on; gzip_vary on; gzip_min_length 1024; gzip_comp_level 6; gzip_types text/plain text/css text/xml text/javascript application/json application/javascript application/xml+rss application/atom+xml image/svg+xml; } ``` --- ### Caching **Static assets:** ```nginx # nginx/conf.d/default.conf location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { expires 1y; add_header Cache-Control "public, immutable"; } ``` **API responses:** ```nginx location /api/ { proxy_cache api_cache; proxy_cache_valid 200 5m; # Cache 200 responses for 5 minutes proxy_cache_bypass $http_cache_control; # Honor Cache-Control header add_header X-Cache-Status $upstream_cache_status; proxy_pass http://api:4000; } ``` --- ### Keep-Alive ```nginx # nginx/nginx.conf http { keepalive_timeout 65; keepalive_requests 100; upstream api { server api:4000; keepalive 32; # Keep 32 connections alive to backend } } ``` --- ## Email Queue Optimization ### Worker Concurrency **Increase parallel processing:** ```typescript // api/src/services/email-queue.service.ts const worker = new Worker('email-queue', emailProcessor, { connection: redis, concurrency: 5, // Process 5 emails simultaneously limiter: { max: 50, // Max 50 jobs per second duration: 1000 } }); ``` **Recommended concurrency:** - Development: 1-2 - Production (low volume): 3-5 - Production (high volume): 10-20 --- ### Batch Processing **Process emails in batches:** ```typescript export const sendBulkEmails = async (emails: Email[]) => { const batchSize = 100; for (let i = 0; i < emails.length; i += batchSize) { const batch = emails.slice(i, i + batchSize); // Add batch to queue await emailQueue.addBulk( batch.map(email => ({ name: 'send-email', data: email })) ); } }; ``` --- ### Rate Limiting **Respect SMTP provider limits:** ```typescript const worker = new Worker('email-queue', emailProcessor, { limiter: { // Gmail: 500 emails/day (free), 2000/day (workspace) max: 100, // 100 emails per hour duration: 3600 * 1000 // 1 hour } }); ``` --- ## Monitoring Performance ### Prometheus Metrics **Track response times:** ```typescript import { Histogram } from 'prom-client'; const httpRequestDuration = new Histogram({ name: 'http_request_duration_seconds', help: 'HTTP request duration in seconds', labelNames: ['method', 'route', 'status'], buckets: [0.01, 0.05, 0.1, 0.5, 1, 5] }); // Middleware to track duration app.use((req, res, next) => { const start = Date.now(); res.on('finish', () => { const duration = (Date.now() - start) / 1000; httpRequestDuration .labels(req.method, req.route?.path || req.path, res.statusCode.toString()) .observe(duration); }); next(); }); ``` **Track query counts:** ```typescript const dbQueries = new Counter({ name: 'cm_database_queries_total', help: 'Total database queries', labelNames: ['model', 'operation'] }); // In Prisma middleware prisma.$use(async (params, next) => { dbQueries.labels(params.model, params.action).inc(); return next(params); }); ``` --- ### Grafana Dashboards **Create performance dashboard:** ```promql # API response time (p95) histogram_quantile(0.95, rate(http_request_duration_seconds_bucket[5m]) ) # Database query rate rate(cm_database_queries_total[5m]) # Cache hit rate rate(cm_cache_hits_total[5m]) / (rate(cm_cache_hits_total[5m]) + rate(cm_cache_misses_total[5m])) ``` --- ### Slow Query Log **Enable in PostgreSQL:** ```yaml # docker-compose.yml v2-postgres: command: postgres -c log_min_duration_statement=100 # Logs queries taking > 100ms ``` **View slow queries:** ```bash docker compose logs v2-postgres | grep "duration:" # Output: # LOG: duration: 523.456 ms statement: SELECT * FROM "Location" WHERE ... ``` --- ## Load Testing ### k6 Load Testing **Install k6:** ```bash # macOS brew install k6 # Linux sudo apt-key adv --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys C5AD17C747E3415A3642D57D77C6C491D6AC1D69 echo "deb https://dl.k6.io/deb stable main" | sudo tee /etc/apt/sources.list.d/k6.list sudo apt-get update sudo apt-get install k6 ``` **Create test script:** ```javascript // load-test.js import http from 'k6/http'; import { check, sleep } from 'k6'; export const options = { stages: [ { duration: '2m', target: 100 }, // Ramp up to 100 users { duration: '5m', target: 100 }, // Stay at 100 users { duration: '2m', target: 0 }, // Ramp down ], thresholds: { http_req_duration: ['p(95)<500'], // 95% of requests < 500ms }, }; export default function () { // Test login const loginRes = http.post('http://localhost:4000/api/auth/login', { email: 'admin@example.com', password: 'Admin123!', }); check(loginRes, { 'login succeeded': (r) => r.status === 200 }); const token = loginRes.json('accessToken'); // Test API endpoints const headers = { Authorization: `Bearer ${token}` }; const campaignsRes = http.get('http://localhost:4000/api/campaigns', { headers }); check(campaignsRes, { 'campaigns loaded': (r) => r.status === 200 }); const locationsRes = http.get('http://localhost:4000/api/map/locations', { headers }); check(locationsRes, { 'locations loaded': (r) => r.status === 200 }); sleep(1); } ``` **Run test:** ```bash k6 run load-test.js ``` **Interpret results:** ``` ✓ login succeeded ✓ campaigns loaded ✓ locations loaded checks.........................: 100.00% data_received..................: 8.2 MB data_sent......................: 1.1 MB http_req_duration..............: avg=145ms min=12ms med=89ms max=2.1s p(95)=423ms http_reqs......................: 12450 vus............................: 100 vus_max........................: 100 ``` --- ### Apache Bench **Quick load test:** ```bash # 1000 requests, 10 concurrent ab -n 1000 -c 10 http://localhost:4000/api/health # With authentication ab -n 1000 -c 10 -H "Authorization: Bearer TOKEN" http://localhost:4000/api/campaigns ``` --- ## Performance Checklist ### Database - [ ] Indexes on frequently queried columns - [ ] Composite indexes for multi-column queries - [ ] Connection pool sized appropriately - [ ] Slow query log enabled - [ ] VACUUM run regularly (auto by default) - [ ] Read replicas for read-heavy loads ### API - [ ] Redis caching for expensive operations - [ ] Rate limiting on all endpoints - [ ] Pagination on list endpoints - [ ] Response compression enabled - [ ] N+1 queries eliminated - [ ] Select only needed fields ### Frontend - [ ] Route-based code splitting - [ ] Lazy loading for heavy components - [ ] Images optimized and lazy-loaded - [ ] Bundle size < 500KB (gzipped) - [ ] React.memo for expensive components - [ ] useCallback/useMemo for stable references ### Docker - [ ] Multi-stage builds - [ ] Resource limits set - [ ] Health checks configured - [ ] Volumes optimized - [ ] Images use Alpine base ### Nginx - [ ] Gzip compression enabled - [ ] Static asset caching (1 year) - [ ] Keep-alive connections - [ ] Worker processes = CPU cores - [ ] Access logs rotated ### Email Queue - [ ] Worker concurrency optimized - [ ] Rate limiting respects SMTP limits - [ ] Batch processing for bulk sends - [ ] Failed jobs retry with backoff - [ ] Queue size monitored ### Monitoring - [ ] Prometheus metrics collected - [ ] Grafana dashboards created - [ ] Alerts configured - [ ] Slow queries logged - [ ] Resource usage tracked --- ## Related Documentation ### Performance Documentation - [Performance Optimization](performance-optimization.md) - This guide - [Monitoring Issues](monitoring-issues.md) - Observability troubleshooting - [Database Issues](database-issues.md) - Database troubleshooting ### Other Guides - [Architecture Overview](../technical/architecture.md) - System design - [Deployment Guide](../deployment/production.md) - Production setup - [Monitoring Guide](../deployment/monitoring.md) - Monitoring setup ### External Resources - [PostgreSQL Performance Tips](https://wiki.postgresql.org/wiki/Performance_Optimization) - [Prisma Performance Guide](https://www.prisma.io/docs/guides/performance-and-optimization) - [React Performance](https://react.dev/learn/render-and-commit) - [Vite Performance](https://vitejs.dev/guide/performance.html) --- **Last Updated:** February 2026 **Version:** V2.0 **Status:** Complete