Article View

Scroll down to read the full article.

Phantom Backpressure: Unmasking Elusive `net.Socket` Drain Starvation in cgroup-limited Node.js Containers

calendar_month August 28, 2026 |
Quick Summary: Node.js `net.Socket` `drain` events vanishing under cgroup CPU limits and non-default network namespaces? This deep dive reveals the obscure kerne...

Alright, listen up. If you've ever stared at a Node.js service, logs screaming about high memory usage, net.Socket write buffers constantly full, but those glorious drain events just... aren't firing, then you know the special kind of hell I've been through. This isn't your garden-variety event loop block. This is deeper. This is insidious. We're talking about a highly specific, infuriating interaction between Node.js, Linux cgroups, and network namespaces that can throttle your network-bound services into oblivion.

The logs were a mess. console.error about 'buffer full' from our custom network layer, net.Socket.write returning false far too often, yet memory graphs weren't spiking to OOM, just steadily climbing because the outbound buffer wasn't clearing. The first instinct, always, is the event loop. Is some synchronous code blocking it? We instrumented with perf and even 0x flame graphs. Nothing. The Node.js process itself seemed busy, but not stuck. CPU utilization for the container hovered around its cgroup limit (e.g., 50%), but top on the host showed plenty of available CPU. It wasn't CPU starvation in the traditional sense. The event loop was ticking, timers were firing, but the drain events... they were ghosts.

Tangled network cables leading to a bottlenecked drainpipe
Visual representation

Next, networking. TCP buffers, net.core.wmem_max, net.ipv4.tcp_wmem? We cranked them up, checked ss -tunap. The Send-Q was consistently high on the affected sockets, confirming the issue was outbound data queuing. But why wasn't the kernel telling Node.js it could send more? This led us down a rabbit hole of kernel network tunables, which yielded nothing. The problem persisted.

Then we looked at the epoll calls. Running strace -p <NODE_PID> on the container process was revealing. We saw epoll_wait calls, frequently returning 0 (no events) even when we knew the Send-Q had space that just opened up. When it did return events, it was often a batch, and after a noticeable delay. This was the critical clue. The kernel wasn't immediately signaling readiness. Or, if it was, Node.js wasn't being scheduled to receive the signal in a timely manner. This pointed directly to the scheduler and cgroups.

We then performed a rigorous A/B test. Identical Node.js application.

  1. Container A: No CPU limits, default network namespace. Result: Flawless performance, drain events firing instantly.
  2. Container B: CPU limits (e.g., 50%), default network namespace. Result: Performance degradation due to CPU limits, but drain events still fired reliably within that limited CPU budget.
  3. Container C: No CPU limits, non-default network namespace. Result: Performance fine, drain events fine. (Slight overhead from netns, but not catastrophic).
  4. Container D: CPU limits (e.g., 50%), non-default network namespace. Result: The phantom backpressure returns. High Send-Q, delayed drain events, poor throughput.

This combination was the smoking gun. The moment we isolated the network stack into its own namespace and aggressively throttled the CPU, the problem materialized. It suggested an increased scheduling overhead that the CPU limits couldn't account for gracefully.

Affected Environments

This phantom backpressure issue primarily manifests under very specific conditions. Your Node.js service must be running in a container, and that container must be subject to both CPU throttling via cgroups and operating within a non-default network namespace.

Operating System Kernel Version Node.js Version Range Container Runtime
Alpine Linux (container) 4.19.x - 5.10.x (host) 14.x, 16.x (LTS) Docker, containerd
Debian/Ubuntu (container) 4.19.x - 5.10.x (host) 14.x, 16.x (LTS) Docker, containerd

Note: While other versions might be affected, our investigations pinpointed these as the most consistent trigger points. The core issue relies on kernel-level cgroup scheduling and epoll behavior which saw subtle changes across these kernel ranges.

The Root Cause

After weeks of chasing ghosts, including a detour into potential distributed systems pitfalls that ultimately weren't the core issue, we finally unraveled this knot. The problem lies in a subtle, almost invisible interaction between the Linux kernel's cgroup CPU scheduler, the overhead of context switching between network namespaces, and Node.js's reliance on epoll for I/O readiness notifications, specifically for TCP socket write buffer draining.

When a container is subjected to aggressive CPU limits (e.g., --cpu-quota and --cpu-period) AND runs within a non-default network namespace, the kernel's scheduling decisions for event polling become... unpredictable. Node.js (via libuv) registers for EPOLLOUT events to detect when a socket's send buffer has sufficiently cleared, allowing the drain event to fire. The kernel, however, under heavy throttling and increased context switching overhead due to network namespace isolation, sometimes delays delivering these EPOLLOUT events to the Node.js process.

It's not that the kernel forgets them. It's that the scheduling quanta assigned to the Node.js process, when it eventually receives CPU time, is often too short to process the backlog of internal libuv events (including microtasks, timer queues, and the polling results from epoll_wait) necessary to immediately re-evaluate the socket's write status and trigger the drain callback. The process *does* get polled, but the *reaction time* to the poll result is significantly delayed because the CPU is constantly being revoked and reassigned, often to other processes or the kernel itself managing network namespace transitions. This creates a state where the socket could drain, but Node.js is too slow to realize it and emit the event.

A stressed CPU core icon struggling against invisible cgroup shackles
Visual representation

This behavior is exacerbated by Node.js 14.x and 16.x's event loop optimizations which, paradoxically, can be less forgiving under such stressed scheduling conditions compared to earlier versions that might have done more frequent, less optimized checks. While many issues in high-performance environments call for advanced strategies described in the FAANG playbook for distributed systems, this was a much lower-level, almost "physical" bottleneck.

The Fix: A CPU Bandwidth Dance

So, how do you fix a phantom? You give it more air. The immediate, most effective workaround we found was to subtly adjust the CPU cgroup settings. Instead of relying purely on cpu-quota and cpu-period for hard limits, which can be brutal with their "slice and dice" approach to CPU time, you need to soften the blow slightly. The goal isn't necessarily to give more total CPU, but to give more consistent CPU cycles when the process wakes up to handle I/O.

The trick is to introduce --cpu-rt-runtime or adjust the --cpu-shares alongside --cpu-quota, if your scheduler supports it. However, the most universally applicable fix is often to increase the cpu-quota slightly, or, if using a scheduler like CFS, adjusting cpu.cfs_quota_us and cpu.cfs_period_us.

However, for containers, especially Docker, the simplest and most effective solution is to introduce --cpu-rt-runtime. This effectively signals to the kernel that this container needs a minimum amount of "real-time" CPU bandwidth, preventing the scheduler from excessively delaying the critical processing of epoll events due to aggressive throttling and netns context switching. It doesn't mean it gets real-time priority over everything, but it smooths out the micro-bursts of CPU it desperately needs.

Here's the Docker command adjustment:

docker run \
  --cpu-quota=50000 \
  --cpu-period=100000 \
  --cpu-rt-runtime=5000 \
  --net=my_custom_network \
  my_node_app:latest

Breaking it down:

  • --cpu-quota=50000: Allows 50ms of CPU time per period.
  • --cpu-period=100000: The CPU period is 100ms. So, 50% CPU.
  • --cpu-rt-runtime=5000: This is the magic. It tells the kernel to grant the container 5ms of "runtime" within its real-time slice. Even though Node.js isn't strictly real-time, this subtly nudges the scheduler to prioritize its wake-ups for I/O events, preventing the devastating delays in processing epoll notifications when coupled with netns overhead.
  • --net=my_custom_network: Represents your non-default network namespace configuration.

For Kubernetes deployments, you'd translate this to your pod's resources.limits and resources.requests and potentially a RuntimeClass that interacts with cgroup v2 cpu.weight and cpu.max or by carefully tweaking the underlying containerd/Docker daemon configuration that maps these to cgroup settings. The cpu-rt-runtime equivalent is often not directly exposed in Kubernetes standard APIs, requiring deeper modifications to the container runtime or Kubelet configuration, but the principle of ensuring consistent CPU slices holds.

This wasn't about more CPU, it was about smarter CPU scheduling. The takeaway? When your Node.js application hits mysterious I/O bottlenecks under heavy load, and especially when containers, cgroups, and non-default network namespaces are involved, don't just blame the event loop. Dig into the kernel's scheduler. It might be playing a cruel trick on your drain events. A minor tweak can save you weeks of debugging hell.

Discussion

Comments

Read Next