Skip to content
Asistența comercială pentru versiunile care au depășit faza de întreținere LTS este disponibilă prin intermediul partenerului nostru HeroDevs din cadrul Programului de sustenabilitate a ecosistemului OpenJS

Mitigating Denial-of-Service Vulnerability from Unrecoverable Stack Space Exhaustion for React, Next.js, and APM Users

MC
JC

Matteo Collina, Joyee Cheung

Mitigating Denial-of-Service Vulnerability from Unrecoverable Stack Space Exhaustion for React, Next.js, and APM Users

TL;DR

Node.js/V8 makes a best-effort attempt to recover from stack space exhaustion with a catchable error, which frameworks have come to rely on for service availability. A bug that only reproduces when async_hooks are used would break this attempt, causing Node.js to exit with 7 directly without throwing a catchable error when recursions in user code exhaust the stack space. This makes applications whose recursion depth is controlled by unsanitized input vulnerable to Denial-of-Service attacks. This silently affects countless applications because:

  • React Server Components use AsyncLocalStorage
  • Next.js uses AsyncLocalStorage for request context tracking
  • Other frameworks using AsyncLocalStorage for request context may also be affected
  • Every APM tool (Datadog, New Relic, Dynatrace, Elastic APM, OpenTelemetry) uses AsyncLocalStorage or async_hooks.createHook to trace requests

Due to the prevalence of this usage pattern in frameworks, including but not limited to React/Next.js, a significant part of the ecosystem is expected to be affected.

The bug fix is included in a security release because of its widespread impact on the ecosystem. However, this is only a mitigation for the general risk that lies in the ecosystem's dependence on recoverable stack space exhaustion for service availability.

For users of these frameworks/tools and server hosting providers: Update as soon as possible.

For libraries and frameworks: apply a more robust defense against stack space exhaustion to ensure service availability (e.g., limit recursion depth or avoid recursions if the depth can be controlled by an attacker). A recoverable RangeError: Maximum call stack size exceeded is only an unspecified behavior implemented on a best-effort basis, and cannot be depended on for security guarantees.

The Bug

When a stack overflow occurs in user code while async_hooks is enabled, Node.js immediately exits with code 7 instead of allowing try-catch blocks to catch the error. This is a special condition in Node.js that skips the process.on('uncaughtException') handlers, making the exception uncatchable.

Reproduction

import { createHook } from 'node:async_hooks';

// This simulates what APM tools do
createHook({ init() {} }).enable();

function recursive() {
  new Promise(() => {}); // Creates async context
  return recursive();
}

try {
  recursive();
} catch (err) {
  console.log('This never runs', err);
}

Expected: try-catch catches the RangeError Actual: Immediate crash with exit code 7

Why This Affects React and Next.js

React Server Components

React 18+ uses AsyncLocalStorage (which is built on async_hooks) to track the rendering context for Server Components:

// Inside React's internals
import { AsyncLocalStorage } from 'node:async_hooks';

const asyncLocalStorage = new AsyncLocalStorage();

// Every server component render creates async context
async function renderServerComponent(Component, props) {
  return asyncLocalStorage.run({ request: currentRequest }, async () => {
    return <Component {...props} />;
  });
}

Next.js Request Context

Next.js uses AsyncLocalStorage to track request context, cookies, headers, and more:

// Simplified from Next.js internals
import { AsyncLocalStorage } from 'node:async_hooks';

export const requestAsyncStorage = new AsyncLocalStorage();

// Every request creates async context
export function handleRequest(req, res) {
  return requestAsyncStorage.run({ req, res }, async () => {
    // Your page/API handler runs here
  });
}

The Real-World Scenario

Consider a Next.js API route that processes user-submitted JSON:

// pages/api/process.js
export default async function handler(req, res) {
  try {
    const data = req.body;
    const result = processNestedData(data); // Deeply nested = stack overflow
    res.json({ success: true, result });
  } catch (err) {
    // THIS CATCH BLOCK NEVER RUNS
    console.error('Processing failed:', err);
    res.status(500).json({ error: 'Processing failed' });
  }
}

function processNestedData(data) {
  if (Array.isArray(data)) {
    return data.map(item => processNestedData(item));
  }
  return transform(data);
}

A user sending deeply nested JSON can crash your entire server:

[
  [
    [
      [
        [
          [
            [
              [
                [
                  [
                    [
                      [
                        [
                          [
                            [
                              [
                                [
                                  [
                                    [
                                      [
                                        [
                                          [
                                            [
                                              [
                                                /* 50,000 levels deep */
                                              ]
                                            ]
                                          ]
                                        ]
                                      ]
                                    ]
                                  ]
                                ]
                              ]
                            ]
                          ]
                        ]
                      ]
                    ]
                  ]
                ]
              ]
            ]
          ]
        ]
      ]
    ]
  ]
]

Without async_hooks: try-catch catches the RangeError, returns 500, server continues With async_hooks (React/Next.js): Server crashes immediately with exit code 7

Why This Affects Every APM User

Application Performance Monitoring (APM) tools are essential infrastructure for production applications. They track request latency, identify bottlenecks, trace errors to their source, and alert teams when something goes wrong. Companies use APM tools like Datadog, New Relic, Dynatrace, Elastic APM, and OpenTelemetry to maintain visibility into their distributed systems.

To provide this functionality, APM tools need to follow a request as it flows through your application, even across async boundaries. When an HTTP request comes in, is processed by middleware, queries a database, calls an external API, and finally returns a response, the APM needs to correlate all of these operations into a single trace. This requires async context tracking.

Most modern APM tools use AsyncLocalStorage (which is built on async_hooks in versions of Node.js before Node 24) to propagate trace context across async operations. The moment you require('dd-trace'), require('newrelic'), or initialize OpenTelemetry, your application has async_hooks enabled.

The irony is notable: the tools you install to monitor and debug crashes can make a category of crashes behave differently. This is not the fault of the APM tools; they are using Node.js APIs exactly as intended.

Why This Is Only a Mitigation, and The Vulnerability Lies Elsewhere

While this issue has significant practical impact, we want to be clear about why Node.js is treating this fix as a mere mitigation of security vulnerability risks at large:

Stack Space Exhaustion Is Not Specified Behavior

The "Maximum call stack size exceeded" error is not part of the ECMAScript specification. The specification does not impose any limit, assuming infinite stack space; imposing a limit and throwing an error is simply behavior that JavaScript engines implement on a best-effort basis. Building a security model on top of an undocumented, unspecified feature that isn't guaranteed to work consistently would be unreliable.

It's worth noting that even when ECMAScript specifies that proper tail calls should reuse stack frames, this is not implemented by most JavaScript engines today, including V8. And in the few JavaScript engines that do implement it, proper tail calls can block an application with infinite recursion instead of hitting the stack size limit at some point and stopping with an error, which is also a Denial-of-Service factor. This reinforces that stack overflow behavior cannot be relied upon for defending against Denial-of-Service attacks.

V8 Doesn't Treat This as a Security Issue

The stack space handling in Node.js is primarily implemented by V8. JavaScript engines developed for browsers have a different security model, and they do not treat crashes like this as security vulnerabilities (example). This means similar bugs reported in the upstream will not go through vulnerability disclosure procedures, making any security classification by Node.js alone ineffective.

uncaughtException Limitations

The uncaughtException handler is not designed to recover the process after it fires. The Node.js documentation explicitly warns against this pattern. Specifically, the documentation states that "Exceptions thrown from within the event handler will not be caught. Instead, the process will exit with a non-zero exit code, and the stack trace will be printed. This is to avoid infinite recursion."

Trying to invoke the handler after the call stack size is exceeded would itself throw. The fact that it works without promise hooks is largely coincidental rather than guaranteed behavior.

Why We Put It In a Security Release

Although it is a bug fix for an unspecified behavior, we chose to include it in the security release because of its widespread impact on the ecosystem. React Server Components, Next.js, and virtually every APM tool are affected. The fix improves developer experience and makes error handling more predictable.

However, it's important to note that we were fortunate to be able to fix this particular case. There's no guarantee that similar edge cases involving stack overflow and async_hooks can always be addressed. For mission-critical paths that must defend against infinite recursion or stack overflow from recursion whose depth can be controlled by an attacker, always sanitize the input or impose a limit on the depth of recursion by other means.

It's worth noting that large array allocations can suffer from similar issues, like the recent qs vulnerability CVE-2025-15284 showed. It's paramount that developers validate and constrain resource usage that could be controlled by an attacker. The runtime cannot always recover reliably from resource exhaustion after-the-fact.

Technical Deep Dive

How async_hooks Works

When you create a Promise, async_hooks fires callbacks to track the async context:

new Promise()
   V8 promise hook triggered
   async_hooks init callback runs
   Your hook code executes (e.g., APM span creation)

The Fatal TryCatchScope

Node.js wraps async_hooks callbacks in a special error handler called TryCatchScope with CatchMode::kFatal:

// From Node.js internals
void EmitAsyncInit(/* ... */) {
  TryCatchScope try_catch(env, TryCatchScope::CatchMode::kFatal);
  // Run async_hooks callback
}

kFatal means: "If any error occurs here, it's unrecoverable. Exit immediately."

This behavior is documented and intentional. From the async_hooks documentation:

If any AsyncHook callbacks throw, the application will print the stack trace and exit. The exit path does follow that of an uncaught exception, but all 'uncaughtException' listeners are removed, thus forcing the process to exit.

The reason for this error handling behavior is that these callbacks are running at potentially volatile points in an object's lifetime, for example during class construction and destruction. Because of this, it is deemed necessary to bring down the process quickly in order to prevent an unintentional abort in the future.

This design makes sense: if your APM tool's init callback throws an error, the application is in an undefined state. The hook might have partially executed, resources might be leaked, and continuing could cause data corruption. Better to crash fast and loud.

But stack overflow is different. The error doesn't originate in the hook. Instead, it originates in user code. The stack just happens to overflow while the hook is on the call stack. The hook itself is fine; there's no corrupted state to worry about.

The Bug Explained

To understand the bug, we need to understand how promise hooks work.

When you enable async_hooks with an init callback, Node.js registers a promise hook with V8. This hook is invoked by V8 itself every time a Promise is created, not by Node.js JavaScript code. The critical detail is that V8 calls this hook synchronously during the Promise constructor, before new Promise() returns to your code.

The call sequence looks like this:

Your code: new Promise()
   V8 Promise constructor
     V8 calls promise hook (synchronous, before constructor returns)
       Node.js promiseInitHook() [JavaScript]
         emitInitNative() [JavaScript]
           Your async_hooks init callback
     V8 Promise constructor returns
Your code continues...

This means that async_hooks callbacks don't run in isolation. They run on the same call stack as user code. Every new Promise() call adds several stack frames for the hook machinery on top of whatever is already on the stack.

Here's what the stack looks like during deep recursion:

[bottom of stack]
recursive() frame #1
  new Promise()
    V8 promise hook
      async_hooks init callback     TryCatchScope::kFatal active here
recursive() frame #2
  new Promise()
    V8 promise hook
      async_hooks init callback     TryCatchScope::kFatal active here
recursive() frame #3
  new Promise()
    V8 promise hook
      async_hooks init callback     STACK OVERFLOW HAPPENS HERE
[top of stack - limit reached]

Each recursive call adds frames for both the user code AND the async_hooks machinery. When the stack finally overflows, the currently executing code is the async_hooks callback, so the TryCatchScope::kFatal catches it.

The complete sequence when stack overflow occurs:

  1. User code calls new Promise() recursively
  2. Each new Promise() synchronously triggers V8's promise hook
  3. V8 calls into Node.js's promiseInitHook(), then emitInitNative()
  4. The stack fills up with interleaved user code and hook frames
  5. Stack overflow throws a RangeError while inside the hook callback
  6. TryCatchScope::kFatal catches the error
  7. TryCatchScope::~TryCatchScope() calls env_->Exit(ExitCode::kExceptionInFatalExceptionHandler)
  8. Node.js exits with code 7

The error originated in user code (the recursive pattern), but because it manifests while the hook callback is the active frame, it's treated as a fatal hook error.

The Fix

The fix detects stack overflow errors and re-throws them to user code instead of treating them as fatal:


TryCatchScope::~TryCatchScope() {
  // ... simplified
  if (HasCaught() && mode_ == CatchMode::kFatal) {
    Local<Value> exception = Exception();

    // Stack overflow? Re-throw to user code instead of exiting
    if (IsStackOverflowError(env_->isolate(), exception)) {
      ReThrow();
      Reset();
      return;
    }

    // Other fatal errors: exit as before
    FatalException(/* ... */);
  }
}

After this fix:

  • try-catch blocks catch the RangeError as expected
  • Applications can handle the error gracefully
  • Behavior is more consistent with and without async_hooks enabled

A Brief History: From async_hooks to AsyncContextFrame

Understanding this bug requires knowing how Node.js evolved its async context tracking.

The async_hooks Era

async_hooks was introduced in Node.js 8 (2017) as a low-level API to track asynchronous resources. It provides callbacks (init, before, after, destroy) that fire at key points in an async resource's lifecycle. APM tools immediately adopted it to trace requests across async boundaries.

However, async_hooks has significant performance overhead. Every Promise creation, every timer, and every I/O operation triggers these callbacks. This cost is unavoidable when the hooks are enabled.

AsyncLocalStorage

Node.js 12.17.0 (2020) introduced AsyncLocalStorage, a higher-level API built on top of async_hooks. It provides a cleaner interface for the most common use case: storing context that flows through async operations (like request IDs, user sessions, or tracing spans).

React Server Components and Next.js adopted AsyncLocalStorage for request context tracking, unknowingly inheriting all of async_hooks behaviors, including this bug.

The AsyncContextFrame Revolution (Node.js 24+)

In Node.js 24, AsyncLocalStorage was reimplemented using a new V8 feature called AsyncContextFrame. This approach integrates context tracking directly into V8's Promise implementation, eliminating the need for JavaScript callbacks on every async operation.

The result is dramatically better performance. Importantly for this bug, AsyncLocalStorage no longer uses async_hooks.createHook() internally. This is why React and Next.js are not affected by this bug on Node.js 24+.

Note: AsyncLocalStorage is still exported from the async_hooks module for backwards compatibility, even though it no longer uses the async_hooks machinery internally on Node.js 24+. It's also available from node:async_hooks and the newer node:async_context module.

For more details on this evolution and its performance implications, see The Hidden Cost of Context.

Affected Versions

Patched releases available for:

  • Node.js 20.20.0 (LTS)
  • Node.js 22.22.0 (LTS)
  • Node.js 24.13.0 (LTS)
  • Node.js 25.3.0 (Current)

Also affected (no patches, end-of-life):

  • All Node.js versions from 8.x to 18.x (8.x was the first version with async_hooks)

Users on Node.js versions prior to 20.x who cannot upgrade should reach out for commercial support for EOL versions.

Important: React and Next.js Impact by Version

The impact on React Server Components and Next.js varies by Node.js version:

Node.js VersionReact/Next.js Affected?APM Tools Affected?
25.xNo*Depends**
24.xNo*Depends**
22.xYesYes
20.xYesYes
< 20.xYesYes

*Node.js 24+ reimplemented AsyncLocalStorage without using async_hooks.createHook(), so React and Next.js are not affected on these versions.

**APM tools that use only AsyncLocalStorage are not affected on Node.js 24+. APM tools that directly use async_hooks.createHook() are still affected on all versions.

Mitigation

Recommended: Upgrade to the patched versions released on January 13th, 2026.

If you cannot upgrade immediately, consider altering your application to avoid deep recursion, particularly when allocating promises within recursive functions.

Timeline

  • December 7, 2025: React/Next.js team contacted Matteo Collina to report this issue
  • December 8, 2025: Vercel Security team opens the HackerOne report #3456295
  • December 9, 2025: Matteo Collina starts working on a first patch that would defer the stack overflow error to the next macrotick.
  • December 10, 2025: The React/Next.js team validates that this patch did not fix the problem.
  • December 10, 2025: Matteo Collina prepares a different patch that rethrows the error immediately, freeing the stack.
  • December 11, 2025: The React/Next.js team validates that this patch fixes the problem.
  • December 12, 2025: Anna Henningsen identifies a blocker for this strategy. The Node.js team starts brainstorming on alternative solutions.
  • December 16, 2025: Joyee Cheung communicates that Node.js cannot treat this as a vulnerability for the reasons listed in this blog post.
  • December 17, 2025: Anna Henningsen fixes the blocking issue for the patch.
  • January 13, 2026: Patched versions released and disclosure published

Conclusion

This bug highlights how deeply async_hooks has become embedded in the Node.js ecosystem. What started as a low-level debugging API is now a critical dependency for React Server Components, Next.js, every major APM tool, and any code using AsyncLocalStorage.

The fix improves the consistency of stack size limit errors caused by deep recursions. While we were able to address this particular case, developers should be aware that stack overflow behavior is not specified by ECMAScript and should not be relied upon for service availability. If the depth of recursion can be controlled by an attacker, always sanitize the input or impose a limit by other means to restrict the depth, instead of counting on the JS runtime to impose a limit or recover from it with a catchable error.

Users running React RSC, Next.js, or any other framework using AsyncLocalStorage, as well as any APM tool in production, should upgrade to the patched versions released on January 13th, 2026.

Acknowledgments

This fix was developed by Matteo Collina and Anna Henningsen. Thanks to Marco Ippolito for preparing the release and to Rafael Gonzaga, Joyee Cheung, and James Snell for helping with the triaging.

Thanks to Andrew MacPherson for reporting the bug in Next.js/React and to the React and Next.js teams at Meta and Vercel for reporting this issue and providing additional evidence that helped refine the fix. Special thanks to Jimmy Jai, Sebastian Markbage, and Sebastian Silbermann.