Skip to content

Caching

EreoJS provides explicit, tag-based caching that gives you full control over what gets cached and when it's invalidated. Unlike implicit caching systems, you always know what's cached and why.

Philosophy

EreoJS's caching is built on three principles:

  1. Explicit over implicit - You opt into caching, not out
  2. Tag-based invalidation - Invalidate related content together
  3. Composable - Caching rules combine predictably

Route-Level Caching

Configure caching in your route's config:

tsx
// routes/posts/[id].tsx
export const config = {
  cache: {
    maxAge: 3600,              // Cache for 1 hour
    staleWhileRevalidate: 86400, // Serve stale for 24h while revalidating
    tags: ['posts', 'post-123']  // Cache tags for invalidation
  }
}

export const loader = createLoader(async ({ params }) => {
  const post = await db.posts.find(params.id)
  return { post }
})

Cache Options

OptionTypeDescription
maxAgenumberSeconds to cache (0 = no cache)
staleWhileRevalidatenumberSeconds to serve stale while revalidating
tagsstring[]Tags for invalidation
privatebooleanPrevent CDN caching (user-specific content)

Note: For ISR-style periodic revalidation, use the revalidate option on the route's PrerenderConfig instead (see the Rendering Modes guide).

Dynamic Cache Tags

Add tags based on the data:

tsx
export const config = {
  cache: ({ params }) => ({
    maxAge: 3600,
    tags: ['posts', `post-${params.id}`, `author-${post.authorId}`]
  })
}

Data-Level Caching

Cache specific data fetches:

tsx
import { cached, cacheKey } from '@ereo/data'

export const loader = createLoader(async ({ params }) => {
  // Cache this specific fetch
  const post = await cached(
    cacheKey('post', params.id),
    () => db.posts.find(params.id),
    { maxAge: 3600, tags: ['posts', `post-${params.id}`] }
  )

  // Uncached - always fresh
  const views = await db.analytics.getViews(params.id)

  return { post, views }
})

Tag-Based Invalidation

Invalidate cached content by tags:

tsx
import { revalidateTag } from '@ereo/data'

export const action = createAction(async ({ request }) => {
  const formData = await request.formData()
  const post = await db.posts.create(Object.fromEntries(formData))

  // Invalidate all content tagged with 'posts'
  await revalidateTag('posts')

  // Invalidate multiple tags at once — pass them as separate arguments
  await revalidateTag('posts', 'homepage', `author-${post.authorId}`)

  return redirect(`/posts/${post.id}`)
})

Note: revalidateTag accepts variadic arguments, so you can pass one or many tags in a single call: revalidateTag('tag1', 'tag2', 'tag3').

Common Tagging Patterns

tsx
// Collection tag - invalidate all posts
tags: ['posts']

// Individual item tag - invalidate one post
tags: ['posts', `post-${id}`]

// Related content - invalidate when author changes
tags: ['posts', `post-${id}`, `author-${post.authorId}`]

// Category tag - invalidate posts in a category
tags: ['posts', `category-${post.categoryId}`]

// User-specific - invalidate user's content
tags: [`user-${userId}-posts`]

Path-Based Invalidation

Invalidate by URL path:

tsx
import { revalidatePath } from '@ereo/data'

// Invalidate specific path
await revalidatePath('/posts')

// Invalidate with pattern
await revalidatePath('/posts/*')

// Invalidate specific post
await revalidatePath(`/posts/${postId}`)

Cache Adapters

EreoJS supports different cache backends:

Memory Cache (Default)

Built-in memory cache, good for development and single-server deployments:

tsx
import { createCache, createTaggedCache } from '@ereo/core'

// Simple cache
const cache = createCache({
  maxSize: 1000,      // Maximum entries
  defaultTtl: 3600    // Default TTL in seconds
})

// Tagged cache (supports tag-based invalidation)
const taggedCache = createTaggedCache({
  maxSize: 1000
})

You can also use MemoryCache directly from @ereo/data:

tsx
import { MemoryCache } from '@ereo/data'

const cache = new MemoryCache()

Redis Cache

For production with multiple servers:

tsx
// ereo.config.ts
import { defineConfig } from '@ereo/core'
import { redisCache } from '@ereo/cache-redis'

export default defineConfig({
  cache: redisCache({
    url: process.env.REDIS_URL,
    prefix: 'ereo:'
  })
})

Custom Adapter

Implement your own cache adapter:

tsx
import { wrapCacheAdapter } from '@ereo/core'

const customCache = wrapCacheAdapter({
  async get(key) {
    // Return cached value or undefined
  },
  async set(key, value, options) {
    // Store value with TTL and tags
  },
  async delete(key) {
    // Delete cached value
  },
  async invalidateTag(tag) {
    // Invalidate all entries with this tag
  }
})

HTTP Cache Headers

EreoJS automatically sets cache headers based on your config:

tsx
export const config = {
  cache: {
    maxAge: 3600,
    staleWhileRevalidate: 86400,
    private: false
  }
}
// Sets: Cache-Control: public, max-age=3600, stale-while-revalidate=86400
tsx
export const config = {
  cache: {
    maxAge: 60,
    private: true
  }
}
// Sets: Cache-Control: private, max-age=60

Manual Cache Control

Set cache headers in your loader:

tsx
export const loader = createLoader(async ({ request, context }) => {
  const post = await db.posts.find(params.id)

  // Conditional caching
  if (post.isPublished) {
    context.cache.set({
      maxAge: 3600,
      tags: ['posts', `post-${post.id}`]
    })
  } else {
    context.cache.set({ maxAge: 0 }) // No cache for drafts
  }

  return { post }
})

Stale-While-Revalidate

SWR serves cached content immediately while fetching fresh data in the background:

tsx
export const config = {
  cache: {
    maxAge: 60,                  // Fresh for 1 minute
    staleWhileRevalidate: 3600   // Stale OK for 1 hour
  }
}

Timeline:

  • 0-60s: Serve cached response (fresh)
  • 60s-3660s: Serve cached response + fetch fresh in background (stale)
  • 3660s+: Cache expired, wait for fresh response

Revalidation Patterns

On-Demand Revalidation

Invalidate when data changes:

tsx
// After creating a post
export const action = createAction(async ({ request }) => {
  await db.posts.create(/* ... */)
  await revalidateTag('posts')
  return redirect('/posts')
})

// After updating a post
export const action = createAction(async ({ request, params }) => {
  await db.posts.update(params.id, /* ... */)
  await revalidateTag('posts', `post-${params.id}`)
  return redirect(`/posts/${params.id}`)
})

// After deleting a post
export const action = createAction(async ({ params }) => {
  const post = await db.posts.delete(params.id)
  await revalidateTag('posts', `post-${params.id}`, `author-${post.authorId}`)
  return redirect('/posts')
})

Time-Based Revalidation

Revalidate periodically (ISR-style):

tsx
export const config = {
  render: 'ssg',
  cache: {
    revalidate: 60  // Regenerate every 60 seconds
  }
}

Webhook Revalidation

Expose an API route for external revalidation:

tsx
// routes/api/revalidate.ts
import { revalidateTag } from '@ereo/data'

export async function POST(request: Request) {
  const { secret, tag } = await request.json()

  if (secret !== process.env.REVALIDATION_SECRET) {
    return Response.json({ error: 'Invalid secret' }, { status: 401 })
  }

  await revalidateTag(tag)
  return Response.json({ revalidated: true })
}

Cache Debugging

Enable cache debugging in development:

tsx
// ereo.config.ts
export default defineConfig({
  dev: {
    cache: {
      debug: true
    }
  }
})

This logs:

[Cache] HIT posts/123 (age: 45s, tags: posts, post-123)
[Cache] MISS posts/456
[Cache] INVALIDATE tag:posts (12 entries)

Cache Stats

Get cache statistics using the MemoryCache from @ereo/data:

tsx
import { MemoryCache } from '@ereo/data'

const cache = new MemoryCache()

// Later...
const stats = cache.getStats()
console.log(stats)
// { size: 156, tags: 23 }

You can also use createTaggedCache from @ereo/core, which provides the same getStats() method.

Best Practices

  1. Start without caching - Add caching when you identify performance needs

  2. Use granular tags - More tags = more precise invalidation

    tsx
    tags: ['posts', `post-${id}`, `category-${categoryId}`, `author-${authorId}`]
  3. Invalidate conservatively - Better to invalidate too much than too little

  4. Test invalidation - Verify that mutations properly invalidate related caches

  5. Monitor cache hit rates - Low hit rates indicate overly aggressive invalidation

  6. Don't cache user-specific data publicly - Use private: true for personalized content

  7. Consider cache warmup - Pre-populate cache after invalidation for critical paths

Anti-Patterns

Caching user-specific data with public tags

tsx
// Bad: personalized data cached publicly — other users might see it
export const config = {
  cache: { maxAge: 3600, tags: ['dashboard'] }
}

export const loader = createLoader(async ({ context }) => {
  const user = context.get('user')
  return { profile: user.profile, orders: user.orders }
})

// Good: use private caching or include user ID in tags
export const config = {
  cache: { maxAge: 3600, tags: ['dashboard'], private: true }
}

Over-broad invalidation

Invalidating the entire cache when a single item changes defeats the purpose of caching:

tsx
// Bad: invalidate everything
await revalidate({ all: true })

// Good: invalidate specific tags
await revalidateTag(`post-${postId}`)
await revalidateTag('posts')  // Invalidate the list page too

Missing invalidation on mutations

If you create/update/delete data but forget to invalidate, users see stale content. Always pair mutations with appropriate cache invalidation.

Setting maxAge too high without staleWhileRevalidate

A high maxAge without staleWhileRevalidate means users see stale content for the entire maxAge duration. Use staleWhileRevalidate to serve stale content while fetching fresh data in the background.

Debugging Caches

Check cache status using the X-Cache response header in development:

  • HIT — Served from cache
  • MISS — Not in cache, fetched fresh
  • STALE — Served stale content, revalidating in background

Released under the MIT License.