nodejs / nodejs/node

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

Ouverte
#64,879 2 commentaires 2 réactions 0 personnes assignées Voir sur GitHub

Personne n'a encore pris cette issue.

  • #65764 par @aduh95 — fermée sans fusion
Langage dominant
JavaScript
Étoiles
122k
Forks
37.3k
Merge moyen
4 j 2 h
PR mergées (30 j)
283

Description

Summary

Please backport V8 commit https://github.com/v8/v8/commit/c9c0abfa51f06ea3c87c4ee9bb519fa8b01fcbbf, “Reduce stack frame summarization costs,” to the Node.js 24 LTS release line.

The backport should also include the required follow-up fix https://github.com/v8/v8/commit/1a0089053443ae4001fd15b84f1b46f330a0b3fa, 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.

Guide de contribution

Ouvrir le guide de contribution

Par où commencer

  1. Lisez l'issue en entier, puis le guide de contribution du projet.
  2. Signalez en commentaire que vous la prenez — cela évite que deux personnes fassent le même travail.
  3. Forkez le dépôt et travaillez sur une branche.
  4. Ouvrez une pull request qui référence le numéro de l'issue.

Piste de recherche

Examinez les commits V8 c9c0abfa51f06ea3c87c4ee9bb519fa8b01fcbbf et 1a0089053443ae4001fd15b84f1b46f330a0b3fa dans le contexte de la ligne de versions Node.js 24 LTS. Exécutez le benchmark error-construction.js fourni avec des limites de stack de 10 et 50, puis vérifiez que le backport améliore le coût de construction tout en préservant le nombre de frames capturées et le comportement existant des stack traces.

Rédigé par le modèle d'indexation à partir du texte de l'issue.

Évaluation

Stack technique
javascript, nodejs
Domaine
backend, performance
Type d'issue
Fonctionnalité
Difficulté
5/5
Temps estimé
Plus d'une semaine
Activité
Active
Clarté
Clairement spécifiée
Accessibilité débutants
35/100

Recevez les nouvelles issues par e-mail

Un résumé court des issues GitHub adaptées aux débutants.