21 KiB
Performance Optimization
This guide covers performance tuning and optimization strategies for Changemaker Lite V2.
Overview
Performance Areas
- Database - Query optimization, indexing, connection pooling
- API - Caching, rate limiting, pagination
- Frontend - Code splitting, lazy loading, bundling
- Docker - Resource limits, multi-stage builds
- Nginx - Compression, caching, keep-alive
- Email Queue - Worker count, batch processing
- 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:
-- 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:
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:
docker compose exec api npx prisma migrate dev --name add_location_indexes
Verify index usage:
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:
// 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:
// 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:
// 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:
// 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:
# 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:
-- 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:
# 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:
// 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:
// 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:
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:
// 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:
// In server.ts
app.use('/api/auth', authRateLimit);
app.use('/api', apiRateLimit);
app.use('/public', publicRateLimit);
Pagination
Implement cursor-based pagination:
// 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:
// admin/src/pages/UsersPage.tsx
const [users, setUsers] = useState([]);
const [cursor, setCursor] = useState<string | null>(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:
// 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:
// 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 (
<Suspense fallback={<Spin />}>
<Routes>
<Route path="/app/users" element={<UsersPage />} />
<Route path="/app/campaigns" element={<CampaignsPage />} />
<Route path="/app/locations" element={<LocationsPage />} />
</Routes>
</Suspense>
);
}
Component splitting:
// Lazy load heavy components
const MapView = lazy(() => import('./components/MapView'));
function Page() {
return (
<Suspense fallback={<Spin />}>
<MapView />
</Suspense>
);
}
Lazy Loading
Images:
<img
src={imageUrl}
loading="lazy" // Native lazy loading
alt="Description"
/>
Large libraries:
// 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:
cd admin
npm run build
npx vite-bundle-visualizer
Tree shaking:
// Import only what you need
import { Button } from 'antd'; // ❌ Imports all of antd
import Button from 'antd/es/button'; // ✅ Only button
Configure Vite:
// 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:
import { memo } from 'react';
const LocationMarker = memo(({ location }) => {
return (
<CircleMarker
center={[location.latitude, location.longitude]}
radius={8}
/>
);
}, (prev, next) => {
// Only re-render if location changed
return prev.location.id === next.location.id;
});
useMemo for expensive calculations:
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 <MapContainer bounds={bounds} />;
}
useCallback for stable functions:
import { useCallback } from 'react';
function Table({ data }) {
// Stable reference for row click handler
const handleRowClick = useCallback((row) => {
console.log('Clicked:', row.id);
}, []);
return <Table data={data} onRowClick={handleRowClick} />;
}
Docker Optimization
Resource Limits
# 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:
docker stats
# Shows:
# CONTAINER CPU % MEM USAGE / LIMIT MEM %
# api 15% 1.2GB / 4GB 30%
Multi-Stage Builds
Optimize 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:
api:
volumes:
- ./api:/app
- /app/node_modules # Don't bind-mount node_modules
- api-build:/app/dist:cached # Cache build output
For macOS/Windows:
api:
volumes:
- ./api:/app:cached # Cached mode for better performance
Nginx Optimization
Gzip Compression
# 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/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:
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.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:
// 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:
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:
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:
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:
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:
# 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:
# docker-compose.yml
v2-postgres:
command: postgres -c log_min_duration_statement=100
# Logs queries taking > 100ms
View slow queries:
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:
# 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:
// 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:
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:
# 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 - This guide
- Monitoring Issues - Observability troubleshooting
- Database Issues - Database troubleshooting
Other Guides
- Architecture Overview - System design
- Deployment Guide - Production setup
- Monitoring Guide - Monitoring setup
External Resources
Last Updated: February 2026 Version: V2.0 Status: Complete