perf: eliminate structuredClone in metadata resolution (914ms → ~0)#91577
perf: eliminate structuredClone in metadata resolution (914ms → ~0)#91577benfavre wants to merge 2 commits intovercel:canaryfrom
Conversation
`structuredClone` in `mergeMetadata` and `mergeViewport` is the vercel#1 CPU hotspot at 914ms (3.6% of request time). With 10 nested layouts, each request triggers 20 structuredClone calls (10 metadata + 10 viewport), deep-cloning the entire resolved metadata/viewport objects every time. After cloning, the merge functions iterate over metadata keys and *replace* every property with a new value — no nested property is ever mutated in-place. This means a shallow object spread (`{ ...obj }`) is semantically equivalent to `structuredClone` for this use case. Replace both `structuredClone` calls with `{ ...obj }` spreads, which are effectively free (~0ms) compared to the deep-clone overhead. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
Allow CI Workflow Run
Note: this should only be enabled once the PR is ready to go and can only be enabled by a maintainer |
|
Allow CI Workflow Run
Note: this should only be enabled once the PR is ready to go and can only be enabled by a maintainer |
The shallow spread `{ ...resolvedMetadata }` kept frozen inner references
in dev mode (where deepFreeze is applied) and caused shared-state leaks
in production. postProcessMetadata mutates nested objects:
- icons.icon.unshift(favicon)
- inheritFromMetadata sets openGraph.title / .description
- Object.assign on twitter
Spread the three mutable nested objects (openGraph, twitter, icons) so
mutations operate on fresh copies.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Test Verification
All tests run on the |
CPU Profile: structuredClone Elimination Verified
The SSR bundle confirms zero All 21 metadata unit tests pass. |
Performance ImpactProfiling setup: Node.js v25.7.0, Before (canary):
After (this PR):
VADE fix included: Deep-copies |
Summary
CPU profiles show
structuredCloneinresolve-metadata.tsas the #1 hotspot at 914ms (3.6% of total request time). With 10 nested layouts, each request triggers 20structuredClonecalls (10 for metadata + 10 for viewport), deep-cloning the entire resolved objects every time.After cloning, both
mergeMetadataandmergeViewportiterate over the metadata/viewport keys and replace every property with a new value via aswitchstatement — no nested property is ever mutated in-place. This means{ ...obj }(shallow spread) is semantically equivalent tostructuredClonefor this use case.Changes
structuredClone(resolvedMetadata)with{ ...resolvedMetadata }inmergeMetadatastructuredClone(resolvedViewport)with{ ...resolvedViewport }inmergeViewportWhy this is safe
Every
casein the merge switch statement does one of:newResolvedMetadata[key] = resolveXyz(...)— assigns a new value from a resolver functionnewResolvedMetadata[key] = metadata[key] ?? null— assigns a scalarnewResolvedMetadata.other = Object.assign({}, newResolvedMetadata.other, metadata.other)— creates a new objectNo case mutates a nested property in-place (e.g.,
obj.prop.nested = ...). The shallow spread preserves the previous iteration's references, but since those references are only read (never written to), there is no aliasing hazard.This also works correctly with the
deepFreezedev-mode path: spreading a frozen object produces a new unfrozen object with the same property values.Impact
structuredCloneper request (20 calls × ~46ms each)Test plan
resolve-metadata.test.tsand related)deepFreeze(dev mode): spreading frozen objects works correctly