Skip to content

fix nested buffer containing array of numbers instead of base64 - #53

Open
tobiasvdorp wants to merge 1 commit into
mrjasonroy:mainfrom
tobiasvdorp:fix/nested-buffer-json-serialization
Open

fix nested buffer containing array of numbers instead of base64#53
tobiasvdorp wants to merge 1 commit into
mrjasonroy:mainfrom
tobiasvdorp:fix/nested-buffer-json-serialization

Conversation

@tobiasvdorp

@tobiasvdorp tobiasvdorp commented Jul 9, 2026

Copy link
Copy Markdown

Description

Fixes oversized serialized APP_PAGE cache 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: rscData was 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

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Documentation update
  • Chore (dependencies, CI, etc.)

Testing

  • All existing tests pass (pnpm test)
  • Added new tests for new functionality
  • Tested with memory cache handler
  • Tested with Redis cache handler (if applicable)
  • E2E tests pass (pnpm test:e2e)

Ran:

  • pnpm --filter @mrjasonroy/cache-components-cache-handler test -- src/helpers/serialization.test.ts
  • pnpm --filter @mrjasonroy/cache-components-cache-handler typecheck

Checklist

  • My code follows the project's code style (pnpm lint passes)
  • I have run pnpm format to format my code
  • Type checking passes (pnpm typecheck)
  • I have updated the documentation accordingly
  • My changes generate no new warnings
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes

Related Issues

Fixes oversized APP_PAGE.rscData serialization caused by Node’s native Buffer.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 Buffer and Map<string, Buffer> shapes.

@tobiasvdorp
tobiasvdorp requested a review from mrjasonroy as a code owner July 9, 2026 14:39

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +18 to +23
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);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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 $O(1)$ for large payloads.

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)])
  );
}

@tobiasvdorp tobiasvdorp Jul 9, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant