Skip to content

Backport v8 "Reduce stack frame summarization costs" patch for React development speedup #64879

Description

@timneutkens

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions