• หน้าหลัก
  • บล็อก
ติดต่อเรา
Infonomic
Menu rocket

INSIGHTS

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

5-6min read

A robotic arm holding a magnifier over a board of linked notes and charts, with more cards sorted in a tray.
  • Home
  • Blog
  • High Performance Next.js 15 Caching and a...

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!

Featured

Rocket ship and AI symbols

Super Cell AI Engineering Teams

Feb 21, 2026
An illustration of an astronaut looking at a blueprint

Requirements vs. Solutions

Aug 04, 2024
Illustration of two astronauts trying to bridge a gap

Expectations and Engagement

Aug 04, 2024

Latest

Where you Got It. Where it Hurt. Little Else is Settled

Sep 02, 2026

The Machinery of "No"

Jun 25, 2026

The Answer Without the Author

Jun 16, 2026

Too Small to Sue, Too Small to License, Ingested all the Same

Jun 09, 2026

ติดตามเรา

สำรวจ

  • หน้าหลัก
  • งานของเรา
  • เกี่ยวกับเรา
  • ติดต่อเรา

แพลตฟอร์ม

  • คลังดิจิทัล
  • การจัดการเนื้อหา
  • การส่งมอบเนื้อหา
  • Byline CMS

บริการ

  • กลยุทธ์
  • ระบบ
  • สร้างสรรค์

กฎหมาย

  • ข้อกำหนดการใช้งาน
  • นโยบายความเป็นส่วนตัว
  • คุกกี้

สร้างด้วย ❤️ โดย Infonomic

ลิขสิทธิ์ © 2026 Infonomic Company Limited สงวนลิขสิทธิ์

ไซต์นี้ได้รับการปกป้องโดย reCAPTCHA และเป็นไปตาม นโยบายความเป็นส่วนตัว และ ข้อกำหนดในการให้บริการ ของ Google