Next.js Cache Management Plan¶
Date: 2025-10-25 Project: PiqueTickets Frontend Status: Planning Priority: Medium
Table of Contents¶
- Overview
- Current Setup Analysis
- Problem Statement
- Proposed Solutions
- Implementation Steps
- Best Practices
- Monitoring & Maintenance
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¶
- Implement interval-based cache invalidation to prevent memory bloat
- Maintain fast page load times and good cache hit rates
- Provide manual cache purge capabilities for on-demand invalidation
- 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:
-
src/app/page.tsx:22 -
src/app/shows/[slug]/page.tsx:111
Memory Optimizations in next.config.ts:8:
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:
- Memory bloat - Accumulated cache entries consuming increasing RAM
- Performance degradation - More memory pressure leads to slower responses
- Container restarts - Out-of-memory errors forcing application restarts
- Increased costs - Need for larger container sizes
Proposed Solutions¶
1. Tagged Cache Strategy (Recommended)¶
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"]}'
- Add additional cron jobs for other entities:
- Producers:
0 1,9,17 * * *(every 8 hours at 1am, 9am, 5pm) - Venues:
0 2 * * *(daily at 2am) - 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:
- Set up cron job pointing to:
https://piquetickets.railway.app/api/cache/purge - Include Authorization header with Bearer token
- 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:
-
Staggered Revalidation - Use different revalidate times:
-
Stale-While-Revalidate - Already implemented in next.config.ts:
-
Gradual Purge - Don't purge all tags at once in cron:
Monitoring & Maintenance¶
Railway Metrics to Monitor¶
- Memory Usage
- Monitor container memory over time
- Alert if sustained above 80% capacity
-
Track correlation with cache purge events
-
CPU Usage
- May spike during cache regeneration
-
Should normalize within 1-2 minutes
-
Response Times
- Track p95 and p99 latencies
- 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:
- Disable Cron Jobs - Stop automated purges immediately
- Increase Revalidate Time - Temporarily increase to 300 seconds
- Monitor Recovery - Watch memory and response times
- Investigate - Check logs for errors during purge
- 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¶
- Load Test - Verify cache behavior under 1000+ concurrent users
- Memory Test - Run container for 7 days and track memory growth
- Purge Test - Verify manual and automated purges work correctly
- Recovery Test - Ensure cache rebuilds properly after purge
References¶
- Next.js Caching Documentation
- Next.js Revalidation
- Railway Cron Jobs
- Incremental Static Regeneration
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