Skip to content

Next.js Cache Management Plan

Date: 2025-10-25 Project: PiqueTickets Frontend Status: Planning Priority: Medium

Table of Contents


Overview

This plan addresses memory buildup in the Next.js frontend due to cache accumulation over time in long-running Railway containers. While Next.js provides ISR (Incremental Static Regeneration) with time-based revalidation, it does not automatically purge old cache entries from memory, which can lead to memory bloat.

Goals

  1. Implement interval-based cache invalidation to prevent memory bloat
  2. Maintain fast page load times and good cache hit rates
  3. Provide manual cache purge capabilities for on-demand invalidation
  4. Set up automated cache cleanup via Railway cron jobs

Current Setup Analysis

Technology Stack

  • Next.js Version: 15.3.1
  • Deployment: Railway (standalone/long-running containers)
  • Router: App Router
  • Current Cache Strategy: ISR with 30-second revalidation

Current Cache Configuration

Files using ISR:

  1. src/app/page.tsx:22

    export const revalidate = 30;
    

  2. src/app/shows/[slug]/page.tsx:111

    export const revalidate = 30;
    

Memory Optimizations in next.config.ts:8:

experimental: {
  webpackMemoryOptimizations: true,
  preloadEntriesOnStart: false,
}

Limitations of Current Setup

  • ✅ 30-second ISR provides fresh content
  • ❌ No automatic purging of stale cache entries
  • ❌ No manual cache invalidation mechanism
  • ❌ No tagged cache for granular control
  • ❌ Cache can grow unbounded over time in long-running containers

Problem Statement

Next.js does not automatically purge cache to free memory at runtime. While the 30-second revalidation ensures content freshness, it doesn't remove old entries from memory. In long-running Railway containers, this can lead to:

  1. Memory bloat - Accumulated cache entries consuming increasing RAM
  2. Performance degradation - More memory pressure leads to slower responses
  3. Container restarts - Out-of-memory errors forcing application restarts
  4. Increased costs - Need for larger container sizes

Proposed Solutions

Use Next.js cache tags to enable granular cache invalidation via revalidateTag() and revalidatePath().

Advantages: - Fine-grained control over what gets purged - Can invalidate related data together - Works well with Railway cron jobs - Native Next.js APIs (stable in v15+)

Implementation Components: - Cache purge API endpoint - Tagged fetch requests - Railway cron job configuration

2. Interval-Based Full Purge

Periodically trigger full cache invalidation via middleware or cron.

Advantages: - Simple to implement - Guaranteed memory cleanup - No need to manage tags

Disadvantages: - Less granular control - May cause temporary performance dip during purge - Could invalidate still-fresh cache

3. Cache Lifecycle Limits

Configure ISR memory cache size limits in Next.js config.

Advantages: - Automatic memory management - No additional infrastructure needed - Built into Next.js

Disadvantages: - Limited configuration options in v15 - May evict frequently-used entries - Less predictable than manual purging


Implementation Steps

Phase 1: Create Cache Purge API Endpoint

File: apps/frontend/src/app/api/cache/purge/route.ts

import { revalidateTag, revalidatePath } from 'next/cache'
import { NextRequest, NextResponse } from 'next/server'

export async function POST(request: NextRequest) {
  // Verify authorization (important for production!)
  const authHeader = request.headers.get('authorization')
  if (authHeader !== `Bearer ${process.env.CACHE_PURGE_SECRET}`) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
  }

  const { tags, paths } = await request.json()

  try {
    // Purge specific tags
    if (tags && Array.isArray(tags)) {
      tags.forEach(tag => revalidateTag(tag))
    }

    // Purge specific paths
    if (paths && Array.isArray(paths)) {
      paths.forEach(path => revalidatePath(path))
    }

    return NextResponse.json({
      success: true,
      purged: { tags, paths },
      timestamp: new Date().toISOString()
    })
  } catch (error) {
    return NextResponse.json({
      error: 'Cache purge failed',
      details: error instanceof Error ? error.message : 'Unknown error'
    }, { status: 500 })
  }
}

// Optional: GET endpoint for cache statistics
export async function GET(request: NextRequest) {
  const authHeader = request.headers.get('authorization')
  if (authHeader !== `Bearer ${process.env.CACHE_PURGE_SECRET}`) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
  }

  return NextResponse.json({
    status: 'healthy',
    timestamp: new Date().toISOString(),
    config: {
      revalidateDefault: 30,
      availableTags: ['shows', 'producers', 'venues', 'series']
    }
  })
}

Phase 2: Update API Fetching with Cache Tags

File: apps/frontend/src/lib/api.ts

Add cache tags to all fetch requests:

// Example for fetchShow function
export async function fetchShow(slug: string) {
  const response = await fetch(
    `${process.env.NEXT_PUBLIC_API_URL}/shows/${slug}`,
    {
      next: {
        revalidate: 30,
        tags: ['shows', `show-${slug}`] // Add tags
      }
    }
  )

  if (!response.ok) {
    throw new Error(`Failed to fetch show: ${response.statusText}`)
  }

  return response.json()
}

// Example for fetchShows function
export async function fetchShows() {
  const response = await fetch(
    `${process.env.NEXT_PUBLIC_API_URL}/shows`,
    {
      next: {
        revalidate: 30,
        tags: ['shows', 'shows-list'] // Add tags
      }
    }
  )

  if (!response.ok) {
    throw new Error(`Failed to fetch shows: ${response.statusText}`)
  }

  return response.json()
}

// Apply similar pattern to:
// - fetchProducers() → tags: ['producers', 'producers-list']
// - fetchProducer(slug) → tags: ['producers', `producer-${slug}`]
// - fetchVenue(id) → tags: ['venues', `venue-${id}`]
// - fetchSeries(slug) → tags: ['series', `series-${slug}`]
// - fetchFeaturedShows() → tags: ['shows', 'featured-shows']
// - fetchActiveCities() → tags: ['cities', 'active-cities']

Phase 3: Environment Configuration

Add to .env.local and Railway environment variables:

# Cache purge secret (generate a strong random string)
CACHE_PURGE_SECRET=your-secure-random-string-here

# Optional: Cache purge schedule (cron format)
CACHE_PURGE_SCHEDULE="0 */4 * * *"  # Every 4 hours

Phase 4: Railway Cron Job Setup

Option A: Railway JSON Configuration

Create/update railway.json in project root:

{
  "$schema": "https://railway.app/railway.schema.json",
  "build": {
    "builder": "NIXPACKS"
  },
  "deploy": {
    "numReplicas": 1,
    "healthcheckPath": "/api/health",
    "restartPolicyType": "ON_FAILURE",
    "restartPolicyMaxRetries": 10
  }
}

Then configure cron via Railway Dashboard: 1. Go to your Frontend service in Railway 2. Navigate to "Cron" tab 3. Add new cron job: - Name: Cache Purge - Shows - Schedule: 0 */4 * * * (every 4 hours) - Command:

curl -X POST https://piquetickets.railway.app/api/cache/purge \
  -H "Authorization: Bearer $CACHE_PURGE_SECRET" \
  -H "Content-Type: application/json" \
  -d '{"tags":["shows"]}'

  1. Add additional cron jobs for other entities:
  2. Producers: 0 1,9,17 * * * (every 8 hours at 1am, 9am, 5pm)
  3. Venues: 0 2 * * * (daily at 2am)
  4. Full cache: 0 3 * * 0 (weekly on Sunday at 3am)

Option B: External Cron Service (Alternative)

Use a service like Upstash QStash, Cron-job.org, or EasyCron:

  1. Set up cron job pointing to: https://piquetickets.railway.app/api/cache/purge
  2. Include Authorization header with Bearer token
  3. Send JSON payload with tags to purge

Phase 5: Update Next.js Config for Memory Limits

File: apps/frontend/next.config.ts

import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  // Performance optimizations
  experimental: {
    optimizeCss: true,
    optimizePackageImports: ['react-icons'],
    webpackMemoryOptimizations: true,
    preloadEntriesOnStart: false,

    // Add ISR memory cache size limit (Next.js 15+)
    // This sets a hard limit on cache size
    isrMemoryCacheSize: 50 * 1024 * 1024, // 50MB cache limit
  },

  // ... rest of existing config
};

export default nextConfig;

Phase 6: Optional Middleware for Automatic Purging

File: apps/frontend/src/middleware.ts

import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

let lastCachePurge = Date.now()
const PURGE_INTERVAL = 4 * 60 * 60 * 1000 // 4 hours in milliseconds

export function middleware(request: NextRequest) {
  // Only run cache check on page requests, not API or static files
  if (request.nextUrl.pathname.startsWith('/api') ||
      request.nextUrl.pathname.startsWith('/_next')) {
    return NextResponse.next()
  }

  // Check if it's time to purge (non-blocking check)
  const now = Date.now()
  if (now - lastCachePurge > PURGE_INTERVAL) {
    lastCachePurge = now

    // Trigger async purge (fire and forget)
    fetch(`${request.nextUrl.origin}/api/cache/purge`, {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${process.env.CACHE_PURGE_SECRET}`,
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({
        tags: ['shows', 'producers'] // Purge most frequently changing data
      })
    }).catch(err => {
      console.error('Background cache purge failed:', err)
    })
  }

  return NextResponse.next()
}

export const config = {
  matcher: [
    /*
     * Match all request paths except:
     * - api (API routes)
     * - _next/static (static files)
     * - _next/image (image optimization files)
     * - favicon.ico (favicon file)
     */
    '/((?!api|_next/static|_next/image|favicon.ico).*)',
  ],
}

Best Practices

Cache Tag Naming Conventions

Use consistent naming patterns for cache tags:

// Entity lists
'shows-list', 'producers-list', 'venues-list'

// Individual entities
'show-{slug}', 'producer-{slug}', 'venue-{id}'

// Related data
'show-tickets-{showId}', 'producer-shows-{producerId}'

// Categories/filters
'shows-featured', 'shows-city-{city}', 'shows-date-{date}'

// Global/shared
'navigation', 'footer', 'homepage'

Purge Schedule Recommendations

Based on data volatility:

Entity Update Frequency Purge Schedule Rationale
Shows High (new shows added daily) Every 4 hours Keep listings fresh
Producers Low (profile updates occasional) Every 8 hours Less frequent changes
Venues Very Low (address/details rarely change) Daily Stable data
Homepage Medium (featured shows rotate) Every 2 hours High visibility page
Full Cache Emergency only Weekly or on-demand Complete cleanup

Monitoring Cache Health

Add logging to cache purge endpoint:

// In purge route.ts
console.log(`Cache purge triggered at ${new Date().toISOString()}`, {
  tags,
  paths,
  triggeredBy: request.headers.get('user-agent')
})

Track metrics: - Purge frequency - Memory usage before/after purge - Response times - Cache hit rates

When to Manually Trigger Cache Purge

Use manual purge for: 1. Data updates - After bulk show imports or updates 2. Emergency fixes - When bad data is cached 3. Feature releases - After deploying UI changes 4. Promotional campaigns - Before launching featured content

Manual purge command:

curl -X POST https://piquetickets.railway.app/api/cache/purge \
  -H "Authorization: Bearer YOUR_SECRET" \
  -H "Content-Type: application/json" \
  -d '{"tags":["shows","producers","homepage"]}'

Avoiding Cache Stampede

Problem: When cache expires, many simultaneous requests can overwhelm backend.

Solutions:

  1. Staggered Revalidation - Use different revalidate times:

    // Homepage - high traffic
    export const revalidate = 30
    
    // Show detail pages - medium traffic
    export const revalidate = 60
    
    // Producer pages - lower traffic
    export const revalidate = 120
    

  2. Stale-While-Revalidate - Already implemented in next.config.ts:

    {
      source: '/shows/:path*',
      headers: [
        {
          key: 'Cache-Control',
          value: 'public, s-maxage=60, stale-while-revalidate=300'
        }
      ]
    }
    

  3. Gradual Purge - Don't purge all tags at once in cron:

    # Purge in stages
    0 */4 * * * - Purge shows
    15 */4 * * * - Purge producers (15 min later)
    30 */4 * * * - Purge venues (30 min later)
    


Monitoring & Maintenance

Railway Metrics to Monitor

  1. Memory Usage
  2. Monitor container memory over time
  3. Alert if sustained above 80% capacity
  4. Track correlation with cache purge events

  5. CPU Usage

  6. May spike during cache regeneration
  7. Should normalize within 1-2 minutes

  8. Response Times

  9. Track p95 and p99 latencies
  10. Should improve after purge as cache refreshes

Logging Strategy

Add structured logging for cache operations:

// Create logging utility
// apps/frontend/src/lib/cache-logger.ts

export function logCachePurge(data: {
  tags?: string[]
  paths?: string[]
  success: boolean
  error?: string
  duration?: number
}) {
  const log = {
    timestamp: new Date().toISOString(),
    event: 'cache_purge',
    ...data
  }

  console.log(JSON.stringify(log))
}

Health Check Endpoint

Create cache health monitoring:

// apps/frontend/src/app/api/cache/health/route.ts

import { NextResponse } from 'next/server'

export async function GET() {
  const health = {
    status: 'healthy',
    timestamp: new Date().toISOString(),
    cache: {
      strategy: 'ISR with tagged invalidation',
      defaultRevalidate: 30,
      tags: ['shows', 'producers', 'venues', 'series'],
      purgeEndpoint: '/api/cache/purge'
    },
    memory: process.memoryUsage()
  }

  return NextResponse.json(health)
}

Rollback Plan

If cache purging causes issues:

  1. Disable Cron Jobs - Stop automated purges immediately
  2. Increase Revalidate Time - Temporarily increase to 300 seconds
  3. Monitor Recovery - Watch memory and response times
  4. Investigate - Check logs for errors during purge
  5. Adjust Schedule - Reduce purge frequency or scope

Implementation Timeline

Week 1: Foundation

  • Create cache purge API endpoint
  • Add environment variables (CACHE_PURGE_SECRET)
  • Test purge endpoint locally
  • Document API usage

Week 2: Tag Implementation

  • Update fetchShow() with tags
  • Update fetchShows() with tags
  • Update fetchProducers() with tags
  • Update fetchProducer() with tags
  • Update other API functions with tags
  • Test tagged cache invalidation

Week 3: Automation

  • Configure Railway cron jobs
  • Set up monitoring/logging
  • Create health check endpoint
  • Test automated purge cycles

Week 4: Optimization

  • Monitor memory usage patterns
  • Adjust purge schedules based on data
  • Implement ISR memory limits
  • Optional: Add middleware-based purging
  • Document final configuration

Success Criteria

Metrics

  • ✅ Memory usage stabilizes below 80% of container capacity
  • ✅ No out-of-memory restarts
  • ✅ Page load times remain under 1 second (p95)
  • ✅ Cache hit rate above 85%
  • ✅ Automated purges complete successfully

Validation Tests

  1. Load Test - Verify cache behavior under 1000+ concurrent users
  2. Memory Test - Run container for 7 days and track memory growth
  3. Purge Test - Verify manual and automated purges work correctly
  4. Recovery Test - Ensure cache rebuilds properly after purge

References


Appendix: Quick Reference Commands

Manual Cache Purge (All Shows)

curl -X POST https://piquetickets.railway.app/api/cache/purge \
  -H "Authorization: Bearer $CACHE_PURGE_SECRET" \
  -H "Content-Type: application/json" \
  -d '{"tags":["shows"]}'

Manual Cache Purge (Specific Show)

curl -X POST https://piquetickets.railway.app/api/cache/purge \
  -H "Authorization: Bearer $CACHE_PURGE_SECRET" \
  -H "Content-Type: application/json" \
  -d '{"tags":["show-comedy-night-arizona"]}'

Manual Path Revalidation

curl -X POST https://piquetickets.railway.app/api/cache/purge \
  -H "Authorization: Bearer $CACHE_PURGE_SECRET" \
  -H "Content-Type: application/json" \
  -d '{"paths":["/","/shows"]}'

Check Cache Health

curl -X GET https://piquetickets.railway.app/api/cache/health \
  -H "Authorization: Bearer $CACHE_PURGE_SECRET"

Last Updated: 2025-10-25 Owner: Engineering Team Reviewers: DevOps, Backend Team