High Performance Next.js 15 Caching and a Replacement for unstable_cache
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!