I'm using Redis for caching in our Next.js application and recently upgraded from v14.2 to v15.3. Previously I've used @neshca/cache-handler for cache handling, but the latest version(1.9.0) of @neshca/cache-handler has no support for Next.js v15.x. So I had to replace the cache handler with the following custom implementation using ioredis.
However, after deployment, CPU usage increased noticeably around 3x to 5x. During peak hours, CPU usage frequently reaches the threshold, causing multiple pods to scale up.
As Next.js changed body to buffer in CachedRouteValue and rscData to Buffer, segmentData to Map<string, Buffer> in CachedAppPageValue, I've added those two Buffer to String and String to Buffer conversion methods.
Can anyone please help me fixing this high CPU usage issue?
CachedRouteValue interface
export interface CachedRouteValue {
kind: CachedRouteKind.APP_ROUTE
// this needs to be a RenderResult so since renderResponse
// expects that type instead of a string
body: Buffer
status: number
headers: OutgoingHttpHeaders
}
CachedAppPageValue interface
export interface CachedAppPageValue {
kind: CachedRouteKind.APP_PAGE
// this needs to be a RenderResult so since renderResponse
// expects that type instead of a string
html: RenderResult
rscData: Buffer | undefined
status: number | undefined
postponed: string | undefined
headers: OutgoingHttpHeaders | undefined
segmentData: Map<string, Buffer> | undefined
}
Current Implementation
const Redis = require("ioredis");
const redisClient = new Redis(
process.env.REDIS_URL ?? "redis://localhost:6379",
);
redisClient.on("error", (error) => {
console.error("Redis error:", error);
});
function calculateTtl(maxAge) {
return maxAge * 1.5;
}
function transformBufferDataForStorage(data) {
const value = data?.value;
if (value?.kind === "APP_PAGE") {
if (value.rscData && Buffer.isBuffer(value.rscData)) {
value.rscData = value.rscData.toString();
}
if (value.segmentData && value.segmentData instanceof Map) {
value.segmentData = Object.fromEntries(
Array.from(value.segmentData.entries()).map(([key, val]) => [
key,
Buffer.isBuffer(val) ? val.toString() : val,
]),
);
}
}
if (
value?.kind === "APP_ROUTE" &&
value?.body &&
Buffer.isBuffer(value.body)
) {
value.body = value.body.toString();
}
return data;
}
function transformStringDataToBuffer(data) {
const value = data?.value;
if (value?.kind === "APP_PAGE") {
if (value.rscData) {
value.rscData = Buffer.from(value.rscData, "utf-8");
}
if (
value.segmentData &&
typeof value.segmentData === "object" &&
!(value.segmentData instanceof Map)
) {
value.segmentData = new Map(
Object.entries(value.segmentData).map(([key, val]) => [
key,
Buffer.from(val, "utf-8"),
]),
);
}
}
if (
value?.kind === "APP_ROUTE" &&
value?.body &&
!Buffer.isBuffer(value.body)
) {
value.body = Buffer.from(value.body, "utf-8");
}
return data;
}
module.exports = class CacheHandler {
constructor(options) {
this.options = options || {};
this.keyPrefix = "storefront:";
this.name = "redis-cache";
}
async get(key) {
const prefixedKey = `${this.keyPrefix}${key}`;
try {
const result = await redisClient.get(prefixedKey);
if (result) {
return transformStringDataToBuffer(JSON.parse(result));
}
} catch (error) {
return null;
}
return null;
}
async set(key, data, ctx) {
const prefixedKey = `${this.keyPrefix}${key}`;
const ttl = calculateTtl(this.options.maxAge || 60 * 60);
const transformedData = transformBufferDataForStorage({ ...data });
const cacheData = {
value: transformedData,
lastModified: Date.now(),
tags: ctx.tags,
};
try {
await redisClient.set(prefixedKey, JSON.stringify(cacheData), "EX", ttl);
} catch (error) {
return false;
}
return true;
}
async revalidateTag(tags) {
tags = [tags].flat();
let cursor = "0";
const tagPattern = `${this.keyPrefix}*`;
const keysToDelete = [];
do {
const [nextCursor, keys] = await redisClient.scan(
cursor,
"MATCH",
tagPattern,
"COUNT",
100,
);
cursor = nextCursor;
if (keys.length > 0) {
const pipeline = redisClient.pipeline();
keys.forEach((key) => pipeline.get(key));
const results = await pipeline.exec();
for (let i = 0; i < keys.length; i++) {
const [err, data] = results[i];
if (!err && data) {
try {
const parsed = JSON.parse(data);
if (
parsed.tags &&
parsed.tags.some((tag) => tags.includes(tag))
) {
keysToDelete.push(keys[i]);
}
} catch (e) {
console.error("Error parsing JSON from Redis:", e);
}
}
}
}
} while (cursor !== "0");
if (keysToDelete.length > 0) {
const pipeline = redisClient.pipeline();
keysToDelete.forEach((key) => pipeline.del(key));
await pipeline.exec();
}
}
};
function removeRedisCacheByPrefix(prefix) {
(async () => {
try {
let cursor = "0";
do {
const [newCursor, keys] = await redisClient.scan(
cursor,
"MATCH",
`${prefix}*`,
"COUNT",
1000,
);
if (keys.length > 0) {
const pipeline = redisClient.pipeline();
keys.forEach((key) => pipeline.del(key));
pipeline
.exec()
.catch((err) =>
console.error("Error in fire-and-forget cache deletion:", err),
);
}
cursor = newCursor;
} while (cursor !== "0");
} catch (error) {
console.error("Error in fire-and-forget cache deletion:", error);
}
})();
return true;
}
module.exports.removeRedisCacheByPrefix = removeRedisCacheByPrefix;