fix nested buffer containing array of numbers instead of base64 - #53
fix nested buffer containing array of numbers instead of base64#53tobiasvdorp wants to merge 1 commit into
Conversation
There was a problem hiding this comment.
Code Review
This pull request addresses an issue where nested Buffer instances are serialized into integer arrays because Node.js calls Buffer.toJSON() before the custom JSON replacer. It introduces a helper function isNodeBufferJson to identify these structures and serialize them as base64 strings, with corresponding reviver updates and unit tests. The review feedback highlights a potential performance bottleneck in isNodeBufferJson when checking large arrays with Array.prototype.every and suggests optimizing this check using a deterministic sampling approach for larger payloads.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| function isNodeBufferJson(value: unknown): value is NodeBufferJson { | ||
| if (!value || typeof value !== "object") return false; | ||
|
|
||
| const obj = value as Record<string, unknown>; | ||
| return obj.type === "Buffer" && Array.isArray(obj.data) && obj.data.every(isByte); | ||
| } |
There was a problem hiding this comment.
Using Array.prototype.every to check every single element of a potentially large nested Buffer (which can be several megabytes in size) is a major performance bottleneck. This check runs during both serialization and deserialization on every cache read/write, which can block the event loop and degrade request latency.
To optimize this, we can perform a full scan only for small arrays (e.g., length <= 100) and use a deterministic sampling check (e.g., checking the first, middle, and last elements) for larger arrays. This maintains high accuracy for typical false-positive cases while keeping the check
function isNodeBufferJson(value: unknown): value is NodeBufferJson {
if (!value || typeof value !== "object") return false;
const obj = value as Record<string, unknown>;
if (obj.type !== "Buffer" || !Array.isArray(obj.data)) return false;
const len = obj.data.length;
if (len === 0) return true;
if (len <= 100) {
return obj.data.every(isByte);
}
return (
isByte(obj.data[0]) &&
isByte(obj.data[len - 1]) &&
isByte(obj.data[Math.floor(len / 2)]) &&
isByte(obj.data[Math.floor(len / 4)]) &&
isByte(obj.data[Math.floor((3 * len) / 4)])
);
}There was a problem hiding this comment.
@gemini-code-assist I checked this against the previous behavior and did a small benchmark.
A full array scan already existed on main in jsonReviver for old/native Buffer JSON entries:
obj.type === "Buffer" &&
Array.isArray(obj.data) &&
obj.data.every((n) => typeof n === "number")This PR adds the same detection on the write path because nested Buffers are converted by Node’s Buffer.toJSON() before the replacer sees them. New-format reads do not scan the large array anymore because they read { __serialized_type: "Buffer", data: "" }.
I also benchmarked RSC-like payloads. The current fix adds some write CPU, but substantially reduces stored size:
| rscData size | Write CPU | Serialized size |
|---|---|---|
| 64 KB | 0.827ms -> 0.880ms | 225KB -> 88KB |
| 512 KB | 6.38ms -> 6.74ms | 1.80MB -> 700KB |
| 1 MB | 12.5ms -> 14.0ms | 3.60MB -> 1.40MB |
| 5 MB | 65.9ms -> 79.6ms | 18.0MB -> 7.0MB |
So this is a write-side CPU tradeoff for about a 61% payload-size reduction in the tested RSC-like cases.
I’d prefer not to use deterministic sampling here because it weakens the serialization boundary: a Buffer-shaped object with invalid unsampled values could be silently converted.
Description
Fixes oversized serialized
APP_PAGEcache entries by normalizing nested Node Buffer JSON into the existing base64 serialization format.The original issue was observed in real Next.js
kind = "APP_PAGE"cache entries:rscDatawas being stored as a very large{ type: "Buffer", data: number[] }object. That number was making entries about 60% larger than expected and needed. Example of the stored shape:"</body></html>\",\"rscData\":{\"type\":\"Buffer\",\"data\":[49,58,34,36,......]"Next.js APP_PAGE cache entries can include rscData as a Buffer. During JSON.stringify(), Node converts nested Buffers to { type: "Buffer", data: number[] } before the replacer sees them, causing those large byte arrays to be stored. This PR detects that shape and serializes it as { __serialized_type: "Buffer", data: "" } instead, while preserving correct Buffer round-tripping on read.
Type of Change
Testing
pnpm test)pnpm test:e2e)Ran:
pnpm --filter @mrjasonroy/cache-components-cache-handler test -- src/helpers/serialization.test.tspnpm --filter @mrjasonroy/cache-components-cache-handler typecheckChecklist
pnpm lintpasses)pnpm formatto format my codepnpm typecheck)Related Issues
Fixes oversized
APP_PAGE.rscDataserialization caused by Node’s nativeBuffer.toJSON()output.Additional Context
This keeps the fix in the shared serialization boundary used by the Redis cache handler, so Next.js cache values continue to round-trip with the expected
BufferandMap<string, Buffer>shapes.