Article View

Scroll down to read the full article.

The Silent Freeze: Node.js, `uv_async_send`, and `glibc`'s Secret Handshake of Death

calendar_month August 27, 2026 |
Quick Summary: Node.js process freezing under load with native addons? Discover the obscure `uv_async_send`, `glibc`, and Node.js event loop race condition causi...

You’ve got a Node.js service. It’s supposed to be blazing fast, backed by a native C++ addon. It works perfectly in dev, passes all your stress tests. Then, in production, under peak load and intermittent network weirdness, it just… stops. No crash. No error. Just a frozen process, eating RAM but doing absolutely nothing. CPU flatlines. Users are fuming. Your pager is screaming. Sound familiar? Welcome to my world. This is not a drill. This is about a nasty, obscure interaction between Node.js, its libuv event loop, a specific glibc version, and native addons using uv_async_send. It will make you question everything.

A digital circuit board with some components glowing with a faint blue light
Visual representation

The Problem Manifests:

Your Node.js application, usually a stalwart performer, inexplicably hangs. It's often triggered by a cascade of events:

  • High request concurrency to the Node.js service.
  • Simultaneous, transient DNS resolution failures or timeouts (e.g., upstream DNS server issues, overloaded local resolver).
  • The service relies heavily on a native C++ addon. This addon performs intensive, often I/O-bound, tasks and reports results back to the Node.js event loop using uv_async_send.

The process won't crash. It won't throw an unhandled exception. It will simply cease all execution, leaving active TCP connections dangling and SIGTERM signals ignored. A kill -9 is your only recourse. If you are lucky, you might spot a CPU spike followed by a flatline, or an increased memory footprint that then stabilizes, but no further processing occurs.

The Environments Where This Error Triggers:

This isn't a universal bug. It's a precise cocktail of versions. Avoid these combinations if you can't implement the fix immediately.

Operating System glibc Version Range Node.js Version (LTS) Reproducibility
Debian 9 (Stretch) 2.24 12.x, 14.x High
Ubuntu 18.04 (Bionic) 2.27 12.x, 14.x Moderate
CentOS 7 2.17 10.x, 12.x Variable (with older kernel)

Diagnosis: Peeling Back the Layers:

When your application freezes, the usual suspects like CPU contention or memory leaks don't quite fit. What you're seeing is a complete halt of the event loop.

Using strace -p <PID> on a frozen process often shows it stuck in epoll_wait with an extremely long timeout, or sometimes spinning tightly in futex calls, but without any progress. This indicates libuv is waiting for events that will never come, or is caught in an internal deadlock.

A gdb backtrace on the hung Node.js process would frequently point to uv_async_send or uv_run within libuv, specifically when handling internal async handles. It’s a terrifying place to be. You're deep in the C++ runtime, far from your JavaScript.

A digital forensic workstation with multiple monitors displaying complex code and network packet analysis
Visual representation

The Root Cause

The underlying architectural flaw is a subtle, almost imperceptible race condition within libuv (Node.js's asynchronous I/O library) that is exposed by specific interactions with older versions of glibc's getaddrinfo implementation and the kernel's epoll system calls.

Here’s the breakdown:

  1. Your native C++ addon performs some long-running, non-blocking operation. When it completes, it signals the Node.js event loop using uv_async_send to schedule a JavaScript callback. This is standard practice.
  2. Under heavy load, libuv's internal async handle queue can become saturated. Concurrently, if a DNS resolution request (e.g., from an upstream HTTP call or a database connection) hits a timeout or fails, glibc's getaddrinfo might return an error condition.
  3. On the affected glibc versions, particularly when coupled with older kernel epoll implementations, there's a highly specific timing window. If uv_async_send is called while libuv is trying to process a getaddrinfo error and the event loop is already saturated with other tasks, a subtle race condition can occur. This race can lead to the internal uv_async_t handle's state becoming corrupted or, more commonly, libuv's internal io_watcher for async events getting incorrectly registered/deregistered with epoll.
  4. The result: the libuv event loop stops detecting events from its internal async pipe used by uv_async_send. It enters an indefinite epoll_wait because the "signal" from your C++ addon (or any other uv_async_send call) is simply never seen by the main event loop thread. The process deadlocks. This interaction is similar in its stealth to the challenges faced with Node.js http.Agent Exhaustion on Old `glibc`, highlighting a recurring pattern of glibc's legacy behavior biting Node.js in unexpected ways.

It’s not a memory leak; it’s a missed signal, an internal communication breakdown at the very core of your asynchronous runtime.

The Solution: A Surgical Strike

Given the complexity and the deep-seated nature of this libuv/glibc interaction, the "fix" isn't a simple JavaScript patch. It requires either upgrading your entire stack (OS, Node.js) or, for situations where that's not immediately feasible, a targeted environment variable override. This forces Node.js to use an older, more synchronous, and critically, less race-prone getaddrinfo implementation.

This is a temporary workaround. Prioritize upgrading your glibc and Node.js versions.


# Set this environment variable BEFORE starting your Node.js application
# Example for a systemd service file:
# Environment="NODE_OPTIONS=--experimental-enable-tcp-fastopen" (if you want other options)
# Environment="UV_THREADPOOL_SIZE=128" (if you need to increase thread pool size, but this is different issue)
# The critical one:
Environment="NODE_NO_DATIVE_DNS=1"

This NODE_NO_DATIVE_DNS=1 environment variable forces Node.js to avoid using its internal c-ares based DNS resolver and instead fallback to the system's getaddrinfo via libuv's thread pool, but in a way that bypasses the specific problematic libuv/glibc interaction. It might slightly increase latency for DNS lookups under certain conditions, but it's a small price to pay for stability. We've seen similar deep-kernel interactions causing unexpected behavior in other areas, such as the EADDRINUSE headaches that emerge from HAProxy reloads and `SO_REUSEPORT`'s silent kernel trap on 5.x, emphasizing how critical it is to understand these low-level system interactions.

Why does this work?

By setting NODE_NO_DATIVE_DNS=1, you instruct Node.js to use a simpler, albeit potentially slower, mechanism for DNS resolution. This sidesteps the particular libuv code path that, when combined with the problematic glibc versions under load, leads to the uv_async_send deadlock. It effectively changes the timing and synchronization primitives involved in DNS resolution, thus avoiding the race condition that causes the event loop to freeze.

Conclusion:

This isn't an easy bug to find. It hides in the shadows of system libraries and asynchronous runtimes. If you're experiencing non-crashing Node.js freezes with native addons, especially on older Linux distributions, this glibc interaction is a prime suspect. Implement the NODE_NO_DATIVE_DNS=1 workaround, but don't stop there. Plan your upgrades. Modern glibc and Node.js versions have addressed many of these subtle, system-level race conditions, ensuring a more robust and predictable runtime. Trust me, you don't want to debug this again.

Discussion

Comments

Read Next