Skip to main content

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:
┌─────────────────────────────────────────┐
│  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
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
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
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
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
'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
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
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
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
-- 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
-- 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
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
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
/** @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
'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
'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
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
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
'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
'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
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
{
  "scripts": {
    "postbuild": "node scripts/warm-cache.ts"
  }
}

3. Graceful Degradation

lib/cache-utils.ts
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
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 revalidateTag is called after mutations
  • Ensure cache keys are consistent
Issue: “Redis connection timeout”
  • Check Redis instance is running
  • Verify connection credentials
  • Implement connection pooling
Issue: “Slow database queries”
  • Check indexes are properly created
  • Use EXPLAIN ANALYZE to identify bottlenecks
  • Consider materialized views for complex queries
Issue: “High memory usage”
  • Review cache TTL values
  • Implement cache size limits
  • Use memory-efficient data structures

Status: Active
Last Updated: December 2025
Performance Target: 95th percentile < 500ms