High Performance Next.js 15 Caching and a Replacement for unstable_cache

5-6min read
A visual language - illustration of hands together

Thanks in part to the movement towards 'servers' in JavaScript frameworks like Next.js 15, Remix (now React Router 7) and Tanstack Start - there's an increased interest server-side caching strategies. In our case we wanted a server-based cache that was framework agnostic, as well as a replacement for Next.js 15 unstable_cache.

We also wanted a 'tagging' system - a bit like Drupal's cache, and others, as well as an in-memory cache that would support clustered machines at Fly.io

We based our solution on Cacheable and CacheManager - an excellent project from Jared Wray.

Here's our CacheManager singleton (yes yes we know - singletons are bad. Use this instead - Awilix).

cache-manager.ts

import { Cache, createCache as createCacheManager } from 'cache-manager'
import { Keyv } from 'keyv'
import { KeyvCacheableMemory } from 'cacheable'
const createCache = (): Cache => {
// ttl = 60 seconds
// lruSize = 5000 the number of LRU cache entries
// For example, if each cache item was 50KB, and all
// 5000 keys were used - we would store 250MB of data.
const store = new KeyvCacheableMemory({ ttl: 60000, lruSize: 5000 })
const keyv = new Keyv({ store })
return createCacheManager({ stores: [keyv] })
}
let cacheManager: Cache | undefined
export const getCacheManager = (): Cache => {
cacheManager ??= createCache()
return cacheManager
}

Next, our tags-manager.ts

import type { Cache } from 'cache-manager'
export interface CacheWithTags {
store(key: string, data: any, options?: { ttl?: number; tags?: string[] }): Promise<void>
wrap<T, Args extends unknown[]>(
key: string,
fn: (...args: Args) => Promise<T>,
options?: { ttl?: number; refreshThreshold?: number; tags?: string[] }
): Promise<T>
retrieve(key: string): Promise<any | null>
invalidateTag(tag: string): Promise<void>
invalidateKey(key: string): Promise<void>
clear(): Promise<true>
}
export class TagsManager implements CacheWithTags {
private cache: Cache
private keyToTagsMap: Map<string, Set<string>>
constructor(cache: any) {
this.cache = cache
this.keyToTagsMap = new Map() // In-memory key-to-tags mapping, not persisted across application restarts
}
// Helper method to add tags to a key's tag list
private _addTagsToKey(key: string, tags?: string[]): void {
if (tags == null) return
if (this.keyToTagsMap.has(key) === false) {
this.keyToTagsMap.set(key, new Set())
}
const keyTags = this.keyToTagsMap.get(key)
const uniqueTags = new Set(tags) // Ensure unique tags
uniqueTags.forEach((tag) => keyTags?.add(tag))
}
// Helper method to remove tags from a key's tag list
private _removeTagsFromKey(key: string): void {
this.keyToTagsMap.delete(key)
}
// Store data in cache with key, data, and options
async store(key: string, data: any, options?: { ttl?: number; tags?: string[] }): Promise<void> {
const { ttl, tags } = options || {}
// Map the key to the provided tags
this._addTagsToKey(key, tags)
// Save the data in the cache
await this.cache.set(key, data, ttl)
}
// Wrap method to manage cacheable operations
async wrap<T, Args extends unknown[]>(
key: string,
fn: (...args: Args) => Promise<T>,
options?: { ttl?: number; refreshThreshold?: number; tags?: string[] }
): Promise<T> {
const { ttl, refreshThreshold, tags } = options || {}
// Map the key to the provided tags
this._addTagsToKey(key, tags)
return await this.cache.wrap(key, fn, ttl, refreshThreshold)
}
// Retrieve data from cache by key
async retrieve(key: string): Promise<any | null> {
return await this.cache.get(key)
}
// Invalidate cache by tag or key
async invalidateTag(tag: string): Promise<void> {
// Invalidate all keys associated with a tag
const keysToInvalidate = Array.from(this.keyToTagsMap.entries())
.filter(([, tags]) => tags.has(tag))
.map(([cacheKey]) => cacheKey)
for (const cacheKey of keysToInvalidate) {
await this.cache.del(cacheKey)
const tags = this.keyToTagsMap.get(cacheKey)
tags?.delete(tag)
if (tags?.size === 0) {
this.keyToTagsMap.delete(cacheKey)
}
}
}
// Invalidate cache by tag or key
async invalidateKey(key: string): Promise<void> {
// Invalidate a specific key and clean up tag mappings
await this.cache.del(key)
this._removeTagsFromKey(key)
}
async clear(): Promise<true> {
await this.cache.clear()
this.keyToTagsMap.clear()
return true
}
}

And now for the magic. Fly.io makes it very easy to retrieve all of the IP addresses for all of the machines serving your application - see Fly.io private networking for more. And so I present to you - cluster-manager.ts (I know what you're thinking).

import { Resolver } from 'dns'
import { stdSerializers } from 'pino'
import { getConfig } from '../../config'
import { getLogger } from '../logger'
export async function resolveInternalDNS(): Promise<{ ipv6Addresses: string[] }> {
const config = getConfig()
const domain = config.privateNetworkDomain
// Create a custom DNS resolver
const resolver = new Resolver()
resolver.setServers(['[fdaa::3]']) // Use Fly.io's custom DNS server
return new Promise((resolve, reject) => {
resolver.resolve6(domain, (err, addresses) => {
if (err) {
return reject(err)
}
resolve({ ipv6Addresses: addresses })
})
})
}
export async function invalidateClusterCacheTag(tag: string) {
return await invalidateClusterCache(tag, 'tag')
}
export async function invalidateClusterCacheKey(key: string) {
return await invalidateClusterCache(key, 'key')
}
export async function invalidateClusterCache(
value: string,
type: 'tag' | 'key'
): Promise<{ ip: string; status: 'success' | 'failed' | 'idle'; data?: any; error?: string }[]> {
const config = getConfig()
const logger = getLogger()
const endpoint = '/someroute/on-every-machine'
const port = config.privateNetworkApplicationPort
const results: {
ip: string
status: 'success' | 'failed' | 'idle'
data?: any
error?: string
}[] = []
try {
const { ipv6Addresses } = await resolveInternalDNS()
if (ipv6Addresses == null || ipv6Addresses.length === 0) {
logger.error({
cluster_cache: {
status: 'failed',
message: 'no ipv6 addresses found via resolveInternalDNS in clusterInvalidateCache',
method: 'clusterInvalidateCache'
}
})
results.push({
ip: 'unknown',
status: 'failed',
error: 'no ipv6 addresses found via resolveInternalDNS in clusterInvalidateCache'
})
}
for (const ip of ipv6Addresses) {
const queryString = type === 'tag' ? `tag=${value}` : `key=${value}`
const url = `http://[${ip}]:${port}${endpoint}?${queryString}` // Format the URL for IPv6
try {
const response = await fetch(url)
if (response.ok) {
const data = await response.json()
logger.info({
cluster_cache: {
status: 'success',
message: 'clusterInvalidateCache successfully called',
data,
method: 'clusterInvalidateCache'
}
})
results.push({ ip, status: 'success', data })
} else {
logger.error({
cluster_cache: {
status: 'failed',
message: 'response not okay clusterInvalidateCache',
method: 'clusterInvalidateCache',
error: `HTTP ${response.status} ${response.statusText}`
}
})
results.push({
ip,
status: 'failed',
error: `HTTP ${response.status} ${response.statusText}`
})
}
} catch (error) {
logger.error({
cluster_cache: {
status: 'failed',
message: 'error in clusterInvalidateCache',
method: 'clusterInvalidateCache',
error: stdSerializers.err(error as Error)
}
})
results.push({
ip,
status: 'failed',
error: error instanceof Error ? error.message : 'Unknown error'
})
}
}
} catch (error) {
logger.error({
cluster_cache: {
status: 'failed',
message: 'error resolving DNS in clusterInvalidateCache',
method: 'clusterInvalidateCache',
error: stdSerializers.err(error as Error)
}
})
results.push({
ip: 'unknown',
status: 'failed',
error: 'error resolving DNS in clusterInvalidateCache'
})
}
return results
}

If we've enabled our cluster cache option - calling invalidateKey or invalidateTag below - will send an HTTP request to an API route handler available on every machine in our application - which will in turn call the correct cache invalidate function for the local in-memory cache.

Here's the top-level cache adapter that takes care of all of the above.

cache.ts

import { getConfig } from '../../config'
import { TagsManager, CacheWithTags } from './tags-manager'
import { getCacheManager } from './cache-manager'
import { invalidateClusterCacheTag, invalidateClusterCacheKey } from './cluster-manager'
const createTagsManager = (): CacheWithTags => {
const cache = getCacheManager()
return new TagsManager(cache)
}
let cache: CacheWithTags | undefined
export const getCache = (): CacheWithTags => {
cache ??= createTagsManager()
return cache
}
export async function store(
key: string,
data: any,
options?: { ttl?: number; tags?: string[] }
): Promise<void> {
const cache = getCache()
await cache.store(key, data, options)
}
export function storeFunction<T, Args extends unknown[]>(
key: string,
fn: (...args: Args) => Promise<T>,
options?: { ttl?: number; refreshThreshold?: number; tags?: string[] }
): (...args: Args) => Promise<T> {
const cache = getCache()
// Return a function that accepts the arguments for `fn`
return async (...args: Args): Promise<T> => {
// Use the internal cache.wrap method
return await cache.wrap(key, () => fn(...args), options)
}
}
export async function retrieve(key: string): Promise<any | null> {
const cache = getCache()
return await cache.retrieve(key)
}
export async function invalidateTag(tag: string): Promise<void> {
const config = getConfig()
if (config.cachingClusterEnabled === true) {
await invalidateClusterCacheTag(tag)
} else {
const cache = getCache()
await cache.invalidateTag(tag)
}
}
export async function invalidateKey(key: string): Promise<void> {
const config = getConfig()
if (config.cachingClusterEnabled === true) {
await invalidateClusterCacheKey(key)
} else {
const cache = getCache()
await cache.invalidateKey(key)
}
}
export async function clear(): Promise<true> {
const cache = getCache()
return await cache.clear()
}

And after all of that, if we want to cache data (with a key and tags) in our data access layer, for example, in calling the Payload CMS local API to get a list of documents - we can do something like this...

export async function getStories(locale: string): Promise<StoriesResponse> {
const config = getConfig()
if (config.cachingDataRequests === true) {
const cacheKey = `stories-list::${locale}`
const cacheTag = `cms::stories`
return storeFunction(cacheKey, getStoriesFn, {
tags: [cacheTag]
})(locale, null)
} else {
return getStoriesFn(locale, null)
}
}

Inside getStoriesFn we call payload.find.

Enjoy!