Summary
Please backport V8 commit c9c0abfa51f0, “Reduce stack frame summarization costs,” to the Node.js 24 LTS release line.
The backport should also include the required follow-up fix 1a0089053443, which falls back to the full frame walk when a receiver is unboxed.
The optimization substantially reduces the synchronous cost of constructing Error objects with stack capture enabled. This cost is paid even before .stack is read because V8 eagerly collects the raw stack-frame information at construction time; only conversion to the formatted stack string is lazy.
React use case
There are two paths in React that heavily rely on Error() in dev.
React element creation
React constructs Error objects in the development implementations of jsx, jsxDEV, and createElement.
Every JSX expression is compiled into one of these element-creation calls. For example:
function App() {
return (
<Layout>
<Header />
<Content />
</Layout>
);
}
Each element in this tree can cause React to capture an Error:
const debugStack = Error('react-stack-top-frame');
The error is not thrown. React stores it as the element’s development-only _debugStack field together with its _owner:
element._debugStack = debugStack;
element._owner = currentOwner;
When the element becomes a Fiber, these fields are copied to fiber._debugStack and fiber._debugOwner. React can later format the captured error to determine the source location where the owner created that element.
This is used for:
- Owner stacks attached to development warnings and errors.
React.captureOwnerStack().
- Component source information displayed by React DevTools.
- Key and invalid-child warnings.
- Server-rendering and RSC debug information.
React uses sentinel frames such as react-stack-top-frame and react_stack_bottom_frame to remove the JSX runtime and other React internals when the error is eventually formatted. The resulting owner stack points back to the application component that created the element.
The capture occurs in several development element-creation paths:
jsxDEV, used by the development JSX transform.
- The development implementations of
jsx and jsxs, including dependencies compiled with the production JSX signature but loaded in a development React build.
createElement, used by the classic JSX transform, manual React.createElement calls, and some libraries.
Because JSX element creation is fundamental to rendering, this is a very hot path. A component tree may create hundreds or thousands of elements, and the captures happen again when components re-render.
React rate-limits detailed owner-stack collection to 10,000 recently created elements. After that it reuses a shared “unknown owner” stack until the counter is periodically reset. Consequently, a development workload can still perform up to 10,000 real Error() captures during each reset period.
This path already temporarily caps Error.stackTraceLimit at 10 to avoid inheriting a much higher application-configured value. However, the V8 optimization remains valuable: in the Node.js 24 benchmark, the patch made Error() construction 2.17× faster even at a stack limit of 10.
These captures are development-only, but development performance is important for server rendering, tests, local application startup, and update responsiveness. Improving V8’s eager stack capture directly reduces the overhead of React’s JSX and createElement hot paths without changing React’s diagnostics.
Promise Tracking
In development, React uses Node.js’s async_hooks API to collect debugging information for asynchronous component execution.
React installs an init hook and handles PROMISE resources. When a relevant Promise is initialized, React constructs an Error and parses its stack to record where the Promise or await originated. This information is associated with the current Server Component owner and later used to produce async debug information and component-owner stacks.
Conceptually, the hot path contains:
createHook({
init(asyncId, type) {
if (type === 'PROMISE') {
const error = new Error();
const stack = parseStackTrace(error);
// Associate the stack with the Promise and component owner.
}
},
});
This callback runs synchronously for every Promise created while the RSC instrumentation is active. Promise-heavy renders can therefore execute this path thousands or tens of thousands of times.
The cost also depends on the ambient value of Error.stackTraceLimit. Although V8 defaults it to 10, applications and development tooling sometimes raise it to values such as 50.
- The patched V8 is over twice as fast even at the default limit of 10.
- Other frameworks, diagnostics libraries, tracing systems, and user code have similar
Error() capture paths.
React immediately parses the captured stack in the real path. The benchmark below deliberately leaves .stack unread during timing to isolate the eager construction work improved by this V8 patch.
Reproduction
Tested with Node.js v24.18.1 at a recursive JavaScript depth of 64:
Benchmark code
'use strict';
const inspector = require('node:inspector');
const {performance} = require('node:perf_hooks');
const limit = Number(process.argv[2]);
if (limit !== 10 && limit !== 50) {
throw new Error('Expected a stack trace limit of 10 or 50');
}
const iterations = 100_000;
const samples = [];
let sink;
Error.stackTraceLimit = limit;
function captureAtDepth(depth) {
if (depth === 0) {
sink = Error();
return;
}
captureAtDepth(depth - 1);
}
function measure() {
const start = performance.now();
for (let i = 0; i < iterations; i++) {
captureAtDepth(64);
}
return performance.now() - start;
}
// Warm up before collecting samples.
for (let i = 0; i < 20_000; i++) {
captureAtDepth(64);
}
for (let i = 0; i < 5; i++) {
samples.push(measure());
}
samples.sort((a, b) => a - b);
console.log(
JSON.stringify(
{
node: process.version,
v8: process.versions.v8,
limit,
iterations,
samples,
median: samples[Math.floor(samples.length / 2)],
// Read only after timing to verify the number of captured frames.
capturedLines: sink.stack.split('\n').length,
inspectorAttached: inspector.url() !== undefined,
execArgv: process.execArgv,
nodeOptions: process.env.NODE_OPTIONS,
},
null,
2,
),
);
Run it in fresh processes with no inspector or NODE_OPTIONS as that affects performance:
env -u NODE_OPTIONS node error-construction.js 10
env -u NODE_OPTIONS node error-construction.js 50
I ran two passes in opposite order, producing 10 timing samples for each configuration.
Results
Both binaries are Node.js v24.18.1 on the same machine and use the same V8 13.6 base. The only V8 version difference is the local backport suffix:
| Node.js v24.18.1 |
Limit 10 |
Limit 50 |
Limit 50 / 10 |
Unpatched, V8 13.6.233.17-node.50 |
376.5 ms |
1,539.2 ms |
4.09× |
Patched, V8 13.6.233.17-node.51 |
173.5 ms |
589.9 ms |
3.40× |
| Improvement |
2.17× |
2.61× |
|
The marginal cost per additional captured frame between limits 10 and 50 falls from approximately 291 ns to 104 ns, a 2.79× improvement.
The stack was not read during the timed section. It was read afterward to verify that the captures contained 11 and 51 lines respectively. The inspector was not attached, process.execArgv was empty, and NODE_OPTIONS was unset.
Expected outcome
It would be highly beneficial for Node.js 24 to incorporate the upstream V8 optimization so that stack-frame collection does less work while preserving existing Error and stack-trace behavior.
The change has been present upstream since April 2026 and has a material effect on framework development instrumentation, error capture, tracing, and other workloads that construct large numbers of Error objects.
Summary
Please backport V8 commit
c9c0abfa51f0, “Reduce stack frame summarization costs,” to the Node.js 24 LTS release line.The backport should also include the required follow-up fix
1a0089053443, which falls back to the full frame walk when a receiver is unboxed.The optimization substantially reduces the synchronous cost of constructing
Errorobjects with stack capture enabled. This cost is paid even before.stackis read because V8 eagerly collects the raw stack-frame information at construction time; only conversion to the formatted stack string is lazy.React use case
There are two paths in React that heavily rely on
Error()in dev.React element creation
React constructs
Errorobjects in the development implementations ofjsx,jsxDEV, andcreateElement.Every JSX expression is compiled into one of these element-creation calls. For example:
Each element in this tree can cause React to capture an
Error:The error is not thrown. React stores it as the element’s development-only
_debugStackfield together with its_owner:When the element becomes a Fiber, these fields are copied to
fiber._debugStackandfiber._debugOwner. React can later format the captured error to determine the source location where the owner created that element.This is used for:
React.captureOwnerStack().React uses sentinel frames such as
react-stack-top-frameandreact_stack_bottom_frameto remove the JSX runtime and other React internals when the error is eventually formatted. The resulting owner stack points back to the application component that created the element.The capture occurs in several development element-creation paths:
jsxDEV, used by the development JSX transform.jsxandjsxs, including dependencies compiled with the production JSX signature but loaded in a development React build.createElement, used by the classic JSX transform, manualReact.createElementcalls, and some libraries.Because JSX element creation is fundamental to rendering, this is a very hot path. A component tree may create hundreds or thousands of elements, and the captures happen again when components re-render.
React rate-limits detailed owner-stack collection to 10,000 recently created elements. After that it reuses a shared “unknown owner” stack until the counter is periodically reset. Consequently, a development workload can still perform up to 10,000 real
Error()captures during each reset period.This path already temporarily caps
Error.stackTraceLimitat 10 to avoid inheriting a much higher application-configured value. However, the V8 optimization remains valuable: in the Node.js 24 benchmark, the patch madeError()construction 2.17× faster even at a stack limit of 10.These captures are development-only, but development performance is important for server rendering, tests, local application startup, and update responsiveness. Improving V8’s eager stack capture directly reduces the overhead of React’s JSX and
createElementhot paths without changing React’s diagnostics.Promise Tracking
In development, React uses Node.js’s
async_hooksAPI to collect debugging information for asynchronous component execution.React installs an
inithook and handlesPROMISEresources. When a relevant Promise is initialized, React constructs anErrorand parses its stack to record where the Promise orawaitoriginated. This information is associated with the current Server Component owner and later used to produce async debug information and component-owner stacks.Conceptually, the hot path contains:
This callback runs synchronously for every Promise created while the RSC instrumentation is active. Promise-heavy renders can therefore execute this path thousands or tens of thousands of times.
The cost also depends on the ambient value of
Error.stackTraceLimit. Although V8 defaults it to 10, applications and development tooling sometimes raise it to values such as 50.Error()capture paths.React immediately parses the captured stack in the real path. The benchmark below deliberately leaves
.stackunread during timing to isolate the eager construction work improved by this V8 patch.Reproduction
Tested with Node.js v24.18.1 at a recursive JavaScript depth of 64:
Benchmark code
Run it in fresh processes with no inspector or
NODE_OPTIONSas that affects performance:I ran two passes in opposite order, producing 10 timing samples for each configuration.
Results
Both binaries are Node.js v24.18.1 on the same machine and use the same V8 13.6 base. The only V8 version difference is the local backport suffix:
13.6.233.17-node.5013.6.233.17-node.51The marginal cost per additional captured frame between limits 10 and 50 falls from approximately 291 ns to 104 ns, a 2.79× improvement.
The stack was not read during the timed section. It was read afterward to verify that the captures contained 11 and 51 lines respectively. The inspector was not attached,
process.execArgvwas empty, andNODE_OPTIONSwas unset.Expected outcome
It would be highly beneficial for Node.js 24 to incorporate the upstream V8 optimization so that stack-frame collection does less work while preserving existing
Errorand stack-trace behavior.The change has been present upstream since April 2026 and has a material effect on framework development instrumentation, error capture, tracing, and other workloads that construct large numbers of
Errorobjects.