---
date: 2026-01-13T17:00:00.000Z
category: vulnerability
title: Mitigating Denial-of-Service Vulnerability from Unrecoverable Stack Space Exhaustion for React, Next.js, and APM Users
slug: january-2026-dos-mitigation-async-hooks
layout: blog-post
author: Matteo Collina and Joyee Cheung
---

## 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. An edge case that reproduces only when `async_hooks` are enabled breaks this recovery path: when recursion in user code exhausts stack space, Node.js exits immediately with exit code 7 instead of throwing a recoverable error. This can be reproduced in countless applications because:

- **React Server Components** use `AsyncLocalStorage`
- **Next.js** uses `AsyncLocalStorage` for request context tracking
- **Other frameworks** may also use `AsyncLocalStorage` for request context tracking
- **Most APM tools** (Datadog, New Relic, Dynatrace, Elastic APM, OpenTelemetry) use `AsyncLocalStorage` or `async_hooks.createHook` to trace requests

The weakness ultimately lies in the ecosystem's reliance on an unspecified behavior in the language - recovery from stack space exhaustion - for service availability ([CWE-758](https://cwe.mitre.org/data/definitions/758.html)). Given the widespread use of `async_hooks` by popular frameworks and APM tools, the aforementioned edge case can expose this weakness more frequently and can present a Denial‑of‑Service vector for many applications. Node.js shipped a mitigation in the January 2026 security release to make this unspecified behavior more consistent, reducing the chance of reproduction. However, the weakness remains in the ecosystem until applications and frameworks move away from relying on unspecified behavior for availability.

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

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

## The Reproduction

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.

```javascript
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:

```javascript
// 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:

```javascript
// 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:

```javascript
// 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:

```json
[[[[[[[[[[[[[[[[[[[[[[[[/* 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 the Use of APM Tools Makes It Easier to Reproduce

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 Behavior Is Not Specified

The "Maximum call stack size exceeded" error is not part of the ECMAScript specification. [The specification does not impose any limit and assumes infinite stack space](https://tc39.es/ecma262/#execution-context-stack). Imposing a limit and throwing a recoverable error are only behaviors that JavaScript engines implement with best-effort. Applications and frameworks are already at risk when they build a security model on top of these unspecified behaviors that are not guaranteed to reproduce consistently, see:

- [CWE-758: Reliance on Undefined, Unspecified, or Implementation-Defined Behavior](https://cwe.mitre.org/data/definitions/758.html)
- [CWE-674: Uncontrolled Recursion](https://cwe.mitre.org/data/definitions/674.html)

It's worth noting that even when ECMAScript specifies that [proper tail calls](https://tc39.es/ecma262/#sec-tail-position-calls) [should reuse stack frames](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Execution_model#tail_calls), this is not implemented by most JavaScript engines today, including V8. And in the few JavaScript engines that do implement it, proper tail calls (as used in [the reproduction above](#the-reproduction)) can block an application with infinite recursion instead of hitting the stack size limit at some point and stopping with an error, which is another Denial-of-Service vector. This reinforces that stack overflow behavior cannot be relied upon for defending against Denial-of-Service attacks.

### This Behavior Is Not Part of The Security Guarantees of V8

In Node.js, the stack space usage from JavaScript function calls is primarily implemented by V8. JavaScript engines developed for browsers have a different security model, and they do not triage crashes like this as security vulnerabilities. This means similar behavior inconsistencies reported in the upstream (like [this](https://issues.chromium.org/issues/432385241)) are not guaranteed to 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."](https://nodejs.org/api/process.html#warning-using-uncaughtexception-correctly)

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 patch for an unspecified behavior, we chose to include it in the security release because of its widespread impact on the ecosystem. The prevalence of `async_hooks` usage by React Server Components, Next.js, and APM tools makes it a practical Denial-of-Service vector for many applications.
Making the unspecified behavior more consistent in Node.js 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`](https://github.com/ljharb/qs) vulnerability [CVE-2025-15284](https://github.com/ljharb/qs/security/advisories/GHSA-6rw7-vpxm-498p) 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`:

```cpp
// 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](https://nodejs.org/api/async_hooks.html#error-handling):

> 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:

```cpp

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`](https://nodejs.org/api/async_hooks.html) was introduced in [Node.js 8 (2017)](https://nodejs.org/en/blog/release/v8.0.0) 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)](https://nodejs.org/en/blog/release/v12.17.0) introduced [`AsyncLocalStorage`](https://nodejs.org/api/async_context.html#class-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](https://nodejs.org/en/blog/release/v24.0.0), `AsyncLocalStorage` was reimplemented using a new V8 feature called [`AsyncContextFrame`](https://github.com/tc39/proposal-async-context). 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](https://blog.platformatic.dev/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](https://nodejs.org/en/about/eol#commercial-support).

### Important: React and Next.js Impact by Version

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

| Node.js Version | React/Next.js Affected? | APM Tools Affected? |
| --------------- | ----------------------- | ------------------- |
| 25.x            | No\*                    | Depends\*\*         |
| 24.x            | No\*                    | Depends\*\*         |
| 22.x            | Yes                     | Yes                 |
| 20.x            | Yes                     | Yes                 |
| < 20.x          | Yes                     | Yes                 |

\*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](https://hackerone.com/reports/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](https://x.com/matteocollina) and [Anna Henningsen](https://github.com/addaleax). Thanks to [Marco Ippolito](https://x.com/satanacchio) for preparing the release and to [Rafael Gonzaga](https://x.com/_rafaelgss), [Joyee Cheung](https://github.com/joyeecheung), and [James Snell](https://bsky.app/profile/jasnell.me) for helping with the triaging.

Thanks to [Andrew MacPherson](https://github.com/AndrewMohawk) for reporting the bug in Next.js/React and to the React and Next.js teams at [Meta](https://www.meta.com/) and [Vercel](https://vercel.com) for reporting this issue and providing additional evidence that helped refine the fix. Special thanks to [Jimmy Jai](https://x.com/feedthejim), [Sebastian Markbage](https://x.com/sebmarkbage), and [Sebastian Silbermann](https://x.com/sebsilbermann).
