Caching & Performance Optimization
This guide covers caching strategies and performance optimization techniques for the Visita Intelligence Platform. Proper caching is essential for delivering fast, responsive civic intelligence to all citizens, regardless of their internet connection quality.Overview
Visita uses a multi-layered caching strategy:Copy
┌─────────────────────────────────────────┐
│ Client-Side Cache (SWR, React Query) │ ← Fastest, user-specific
├─────────────────────────────────────────┤
│ CDN Cache (Vercel Edge Network) │ ← Global, static content
├─────────────────────────────────────────┤
│ Next.js Cache (Data Cache, RSC) │ ← Server-side, shared
├─────────────────────────────────────────┤
│ Redis Cache (Upstash) │ ← Application cache
├─────────────────────────────────────────┤
│ Database Cache (Materialized Views) │ ← Persistent, computed data
└─────────────────────────────────────────┘
Next.js Caching Layers
1. Data Cache (Persistent)
The Data Cache is Next.js’s most persistent cache layer, shared across users and requests.app/ward/[wardCode]/page.tsx
Copy
import { unstable_cache } from 'next/cache';
import { createClient } from '@/lib/supabase/server';
// Cache ward data for 1 hour
export async function getWardData(wardCode: string) {
return unstable_cache(
async () => {
const supabase = await createClient();
const { data, error } = await supabase
.from('wards')
.select('*')
.eq('ward_code', wardCode)
.single();
if (error) throw error;
return data;
},
[`ward-${wardCode}`],
{
revalidate: 3600, // 1 hour
tags: [`ward-${wardCode}`],
}
)();
}
// Cache ward signals for 15 minutes
export async function getWardSignals(wardCode: string) {
return unstable_cache(
async () => {
const supabase = await createClient();
const { data, error } = await supabase
.from('signals')
.select('*')
.eq('ward_code', wardCode)
.order('created_at', { ascending: false })
.limit(50);
if (error) throw error;
return data;
},
[`ward-signals-${wardCode}`],
{
revalidate: 900, // 15 minutes
tags: [`ward-signals-${wardCode}`],
}
)();
}
export default async function WardPage({
params,
}: {
params: Promise<{ wardCode: string }>;
}) {
const { wardCode } = await params;
// Fetch data in parallel
const [ward, signals] = await Promise.all([
getWardData(wardCode),
getWardSignals(wardCode),
]);
return (
<div>
<WardHeader ward={ward} />
<SignalList signals={signals} />
</div>
);
}
2. Full Route Cache (Static)
For pages that can be fully static, use the Full Route Cache.app/methodology/page.tsx
Copy
import { cache } from 'react';
// Mark as static
export const revalidate = 86400; // 24 hours
// Use cache() for expensive computations
const getMethodologyData = cache(async () => {
// This will only run once per day
const data = await fetch('https://api.example.com/methodology');
return data.json();
});
export default async function MethodologyPage() {
const data = await getMethodologyData();
return (
<article className="prose max-w-none">
<h1>{data.title}</h1>
<div dangerouslySetInnerHTML={{ __html: data.content }} />
</article>
);
}
3. Router Cache (Client-Side)
The Router Cache stores route segments temporarily on the client.app/ward/[wardCode]/layout.tsx
Copy
export default async function WardLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ wardCode: string }>;
}) {
const { wardCode } = await params;
// This layout will be cached for 5 minutes
return (
<div className="ward-layout">
<WardNavigation wardCode={wardCode} />
<main>{children}</main>
</div>
);
}
// Cache layout for 5 minutes
export const revalidate = 300;
Redis Caching
Setup
lib/redis.ts
Copy
import { Redis } from '@upstash/redis';
export const redis = new Redis({
url: process.env.UPSTASH_REDIS_REST_URL!,
token: process.env.UPSTASH_REDIS_REST_TOKEN!,
});
// Helper function with error handling
export async function cached<T>(
key: string,
fetcher: () => Promise<T>,
ttl: number = 3600
): Promise<T> {
try {
// Try to get from cache
const cached = await redis.get(key);
if (cached !== null) {
return cached as T;
}
// Fetch fresh data
const data = await fetcher();
// Store in cache
await redis.setex(key, ttl, data);
return data;
} catch (error) {
console.error('Cache error:', error);
// Fallback to direct fetch
return await fetcher();
}
}
Usage Examples
app/actions/ward-intelligence.ts
Copy
'use server';
import { redis, cached } from '@/lib/redis';
import { createClient } from '@/lib/supabase/server';
// Cache ward statistics for 30 minutes
export async function getWardStats(wardCode: string) {
return cached(
`ward-stats:${wardCode}`,
async () => {
const supabase = await createClient();
const { data, error } = await supabase
.from('ward_statistics')
.select('*')
.eq('ward_code', wardCode)
.single();
if (error) throw error;
return data;
},
1800 // 30 minutes
);
}
// Cache weather data for 1 hour
export async function getWardWeather(wardCode: string) {
return cached(
`weather:${wardCode}`,
async () => {
const response = await fetch(
`https://api.weather.com/v1/wards/${wardCode}/current`,
{ headers: { 'Authorization': `Bearer ${process.env.WEATHER_API_KEY}` } }
);
if (!response.ok) {
throw new Error('Failed to fetch weather');
}
return response.json();
},
3600 // 1 hour
);
}
// Invalidate cache when data changes
export async function invalidateWardCache(wardCode: string) {
await redis.del(`ward-stats:${wardCode}`);
await redis.del(`ward-signals:${wardCode}`);
await redis.del(`weather:${wardCode}`);
// Also invalidate Next.js cache tags
revalidateTag(`ward-${wardCode}`);
}
Cache Warming
jobs/cache-warm.ts
Copy
import { redis } from '@/lib/redis';
import { createClient } from '@/lib/supabase/server';
// Pre-populate cache for popular wards
export async function warmWardCache() {
const supabase = await createClient();
// Get top 20 most visited wards
const { data: wards } = await supabase
.from('wards')
.select('ward_code')
.order('visit_count', { ascending: false })
.limit(20);
if (!wards) return;
// Warm cache for each ward
await Promise.all(
wards.map(async (ward) => {
try {
// Fetch and cache data
await getWardStats(ward.ward_code);
await getWardSignals(ward.ward_code);
await getWardWeather(ward.ward_code);
console.log(`Warmed cache for ${ward.ward_code}`);
} catch (error) {
console.error(`Failed to warm cache for ${ward.ward_code}:`, error);
}
})
);
}
// Run every hour
export const cron = '0 * * * *';
Client-Side Caching
SWR for Data Fetching
hooks/useWardData.ts
Copy
import useSWR from 'swr';
import { fetcher } from '@/lib/fetcher';
export function useWardData(wardCode: string) {
const { data, error, isLoading, mutate } = useSWR(
wardCode ? `/api/wards/${wardCode}` : null,
fetcher,
{
revalidateOnFocus: false,
revalidateOnReconnect: true,
dedupingInterval: 60000, // 1 minute
focusThrottleInterval: 300000, // 5 minutes
}
);
return {
ward: data,
isLoading,
error,
refresh: mutate,
};
}
export function useWardSignals(wardCode: string) {
const { data, error, isLoading, mutate } = useSWR(
wardCode ? `/api/wards/${wardCode}/signals` : null,
fetcher,
{
refreshInterval: 300000, // 5 minutes
revalidateOnFocus: false,
}
);
return {
signals: data || [],
isLoading,
error,
refresh: mutate,
};
}
React Query Alternative
hooks/useWardQuery.ts
Copy
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { createClient } from '@/lib/supabase/client';
const supabase = createClient();
export function useWardData(wardCode: string) {
return useQuery({
queryKey: ['ward', wardCode],
queryFn: async () => {
const { data, error } = await supabase
.from('wards')
.select('*')
.eq('ward_code', wardCode)
.single();
if (error) throw error;
return data;
},
staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 30 * 60 * 1000, // 30 minutes
enabled: !!wardCode,
});
}
export function useSubmitSignal() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (signal: SignalFormData) => {
const { data, error } = await supabase
.from('signals')
.insert([signal])
.select()
.single();
if (error) throw error;
return data;
},
onSuccess: (data, variables) => {
// Invalidate signals query
queryClient.invalidateQueries({
queryKey: ['signals', variables.wardCode],
});
},
});
}
Database Optimization
Materialized Views
supabase/migrations/20250101_ward_statistics_view.sql
Copy
-- Create materialized view for ward statistics
CREATE MATERIALIZED VIEW ward_statistics AS
SELECT
w.ward_code,
w.name,
w.population,
COUNT(s.id) as total_signals,
COUNT(CASE WHEN s.status = 'resolved' THEN 1 END) as resolved_signals,
COUNT(CASE WHEN s.status = 'pending' THEN 1 END) as pending_signals,
COUNT(p.id) as active_projects,
COALESCE(SUM(p.budget), 0) as total_project_budget,
AVG(sr.rating) as average_satisfaction_rating
FROM wards w
LEFT JOIN signals s ON w.ward_code = s.ward_code
LEFT JOIN community_projects p ON w.ward_code = p.ward_code AND p.status = 'active'
LEFT JOIN satisfaction_ratings sr ON w.ward_code = sr.ward_code
GROUP BY w.ward_code, w.name, w.population;
-- Create unique index
CREATE UNIQUE INDEX idx_ward_statistics_code ON ward_statistics(ward_code);
-- Create refresh function
CREATE OR REPLACE FUNCTION refresh_ward_statistics()
RETURNS void AS $$
BEGIN
REFRESH MATERIALIZED VIEW CONCURRENTLY ward_statistics;
END;
$$ LANGUAGE plpgsql;
Indexing Strategy
supabase/migrations/20250101_optimize_indexes.sql
Copy
-- Composite indexes for common queries
CREATE INDEX idx_signals_ward_status_date ON signals(ward_code, status, created_at DESC);
CREATE INDEX idx_projects_ward_status_budget ON community_projects(ward_code, status, budget DESC);
CREATE INDEX idx_events_ward_date ON events(ward_code, event_date);
-- Partial indexes for active records
CREATE INDEX idx_signals_active ON signals(ward_code, created_at)
WHERE status IN ('pending', 'in_progress');
-- GIN indexes for full-text search
CREATE INDEX idx_wards_name_search ON wards USING GIN(to_tsvector('english', name));
CREATE INDEX idx_signals_content_search ON signals USING GIN(to_tsvector('english', title || ' ' || description));
Image Optimization
Next.js Image Component
components/WardGallery.tsx
Copy
import Image from 'next/image';
export function WardGallery({ images }: { images: WardImage[] }) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{images.map((image) => (
<div key={image.id} className="relative aspect-square">
<Image
src={image.url}
alt={image.caption || 'Ward photo'}
fill
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
placeholder="blur"
blurDataURL={image.blurhash || 'data:image/jpeg;base64,...'}
className="object-cover rounded-lg"
/>
</div>
))}
</div>
);
}
Custom Image Loader
lib/image-loader.ts
Copy
export function customImageLoader({
src,
width,
quality,
}: {
src: string;
width: number;
quality?: number;
}) {
const params = new URLSearchParams({
url: src,
w: width.toString(),
q: (quality || 75).toString(),
fm: 'webp',
});
return `https://images.visita.co.za/api/image?${params}`;
}
next.config.mjs
Copy
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
loader: 'custom',
loaderFile: './lib/image-loader.ts',
formats: ['image/webp', 'image/avif'],
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
},
experimental: {
optimizeCss: true,
},
};
export default nextConfig;
Code Splitting
Dynamic Imports
components/ward/WardMap.tsx
Copy
'use client';
import dynamic from 'next/dynamic';
// Load heavy components dynamically
const MapComponent = dynamic(
() => import('@/components/maps/LeafletMap'),
{
ssr: false,
loading: () => (
<div className="w-full h-96 bg-gray-200 animate-pulse rounded-lg" />
),
}
);
const ChartComponent = dynamic(
() => import('@/components/charts/StatisticsChart'),
{
loading: () => (
<div className="w-full h-64 bg-gray-100 animate-pulse rounded" />
),
}
);
export function WardMap({ wardCode }: { wardCode: string }) {
return (
<div className="space-y-4">
<MapComponent wardCode={wardCode} />
<ChartComponent wardCode={wardCode} />
</div>
);
}
Route-Based Splitting
app/admin/layout.tsx
Copy
'use client';
import { useState } from 'react';
import dynamic from 'next/dynamic';
// Only load admin components when needed
const AdminPanel = dynamic(() => import('@/components/admin/AdminPanel'));
const AnalyticsDashboard = dynamic(() => import('@/components/admin/AnalyticsDashboard'));
export default function AdminLayout({
children,
}: {
children: React.ReactNode;
}) {
const [activeTab, setActiveTab] = useState('overview');
return (
<div className="flex">
<nav className="w-64 bg-gray-100 p-4">
<button onClick={() => setActiveTab('overview')}>Overview</button>
<button onClick={() => setActiveTab('analytics')}>Analytics</button>
</nav>
<main className="flex-1 p-4">
{activeTab === 'overview' && <AdminPanel />}
{activeTab === 'analytics' && <AnalyticsDashboard />}
{children}
</main>
</div>
);
}
Performance Monitoring
Core Web Vitals
app/layout.tsx
Copy
import { SpeedInsights } from '@vercel/speed-insights/next';
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
{children}
<SpeedInsights />
</body>
</html>
);
}
Custom Metrics
lib/performance.ts
Copy
export function trackPerformance(metricName: string, value: number) {
if (typeof window !== 'undefined' && 'PerformanceObserver' in window) {
// Send to analytics
fetch('/api/analytics/performance', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
metric: metricName,
value,
timestamp: Date.now(),
url: window.location.pathname,
}),
}).catch(() => {
// Ignore errors
});
}
}
// Measure page load time
export function measurePageLoad() {
if (typeof window === 'undefined') return;
window.addEventListener('load', () => {
setTimeout(() => {
const navigation = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming;
trackPerformance('page_load', navigation.loadEventEnd - navigation.fetchStart);
trackPerformance('first_contentful_paint', performance.getEntriesByName('first-contentful-paint')[0]?.startTime || 0);
trackPerformance('largest_contentful_paint', performance.getEntriesByName('largest-contentful-paint')[0]?.startTime || 0);
}, 0);
});
}
Real User Monitoring (RUM)
components/PerformanceMonitor.tsx
Copy
'use client';
import { useEffect } from 'react';
import { trackPerformance } from '@/lib/performance';
export function PerformanceMonitor() {
useEffect(() => {
// Track component render time
const startTime = performance.now();
return () => {
const renderTime = performance.now() - startTime;
trackPerformance('component_render', renderTime);
};
}, []);
useEffect(() => {
// Track Core Web Vitals
if ('PerformanceObserver' in window) {
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
trackPerformance(entry.name, entry.startTime);
}
});
observer.observe({ entryTypes: ['paint', 'largest-contentful-paint'] });
return () => observer.disconnect();
}
}, []);
return null;
}
Caching Best Practices
1. Cache Invalidation Strategy
app/actions/signals.ts
Copy
'use server';
import { revalidateTag } from 'next/cache';
import { redis } from '@/lib/redis';
export async function submitSignal(formData: FormData) {
const wardCode = formData.get('wardCode') as string;
try {
// Insert into database
const { error } = await supabase
.from('signals')
.insert({ ...data, ward_code: wardCode });
if (error) throw error;
// Invalidate caches
await Promise.all([
// Next.js cache
revalidateTag(`ward-signals-${wardCode}`),
revalidateTag(`ward-${wardCode}`),
// Redis cache
redis.del(`ward-stats:${wardCode}`),
redis.del(`ward-signals:${wardCode}`),
]);
return { success: true };
} catch (error) {
console.error('Signal submission error:', error);
return { success: false };
}
}
2. Cache Warming on Deployment
scripts/warm-cache.ts
Copy
import { warmWardCache } from '@/jobs/cache-warm';
async function main() {
console.log('Warming cache after deployment...');
try {
await warmWardCache();
console.log('Cache warmed successfully');
} catch (error) {
console.error('Failed to warm cache:', error);
process.exit(1);
}
}
main();
package.json
Copy
{
"scripts": {
"postbuild": "node scripts/warm-cache.ts"
}
}
3. Graceful Degradation
lib/cache-utils.ts
Copy
export async function getCachedData<T>(
key: string,
fetcher: () => Promise<T>,
ttl: number = 3600
): Promise<T> {
try {
// Try Redis first
const cached = await redis.get(key);
if (cached) return cached as T;
} catch (error) {
console.warn('Redis cache miss, falling back to direct fetch');
}
try {
// Fetch fresh data
const data = await fetcher();
// Try to cache (non-blocking)
redis.setex(key, ttl, data).catch(() => {
console.warn('Failed to cache data');
});
return data;
} catch (error) {
console.error('Failed to fetch data:', error);
throw error;
}
}
Performance Testing
Load Testing Script
tests/load-test.ts
Copy
import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = {
stages: [
{ duration: '1m', target: 50 }, // Ramp up to 50 users
{ duration: '3m', target: 50 }, // Hold at 50 users
{ duration: '1m', target: 100 }, // Ramp up to 100 users
{ duration: '3m', target: 100 }, // Hold at 100 users
{ duration: '1m', target: 0 }, // Ramp down
],
thresholds: {
http_req_duration: ['p(95)<500'], // 95% of requests should be < 500ms
http_req_failed: ['rate<0.01'], // Error rate should be < 1%
},
};
export default function () {
// Test ward page
const wardResponse = http.get('https://visita.co.za/ward/WARD-123');
check(wardResponse, {
'ward page returns 200': (r) => r.status === 200,
'ward page loads quickly': (r) => r.timings.duration < 1000,
});
sleep(1);
// Test API endpoint
const apiResponse = http.get('https://visita.co.za/api/wards/WARD-123');
check(apiResponse, {
'api returns 200': (r) => r.status === 200,
'api responds quickly': (r) => r.timings.duration < 200,
});
sleep(2);
}
Troubleshooting
Common Performance Issues
Issue: “Cache not invalidating”- Check cache tags are correctly set
- Verify
revalidateTagis called after mutations - Ensure cache keys are consistent
- Check Redis instance is running
- Verify connection credentials
- Implement connection pooling
- Check indexes are properly created
- Use
EXPLAIN ANALYZEto identify bottlenecks - Consider materialized views for complex queries
- Review cache TTL values
- Implement cache size limits
- Use memory-efficient data structures
Related Documentation
- Database Schema - Database optimization
- Deployment - Production optimization
- Testing - Performance testing
- Authentication - Caching auth state
Status: Active
Last Updated: December 2025
Performance Target: 95th percentile < 500ms