Bali, Indonesia
Insights

Why Disk Writeback Stalls in Nginx Proxy Cache Silently Kill HTTP/3 UDP Sockets

October 14, 2026
0:00
When edge infrastructure teams migrate edge traffic to HTTP/3, the performance promises are alluring: zero round-trip handshakes, zero head-of-line blocking across independent streams, and rapid connection migration. However, operational realities often deliver a much harsher outcome under sustained peak load. Teams migrating busy reverse proxies frequently report erratic connection drops, abrupt spikes in client retransmissions, and mysterious browser fallbacks to HTTP/2. An investigation by Nextgen Engineering Desk in their analysis of Troubleshooting HTTP/3 QUIC Connection Drops: UDP Buffer Saturation, PMTU Black Holes, and 0-RTT Failures in Nginx & LiteSpeed documented how user-space UDP buffer depletion triggers silent connection collapse. While network engineers frequently look for Path MTU black holes or conntrack table exhaustion, the real villain in proxy deployments is often hiding inside local disk I/O: dirty page cache writeback stalls within the Nginx proxy cache.
To understand why disk writeback breaks HTTP/3 while leaving HTTP/2 relatively unharmed, one must look at where protocol state engines live. In traditional HTTP/2 over TLS, the transport layer is managed directly by the Linux kernel. When an incoming TCP packet arrives, the network interface card (NIC) triggers an interrupt or softirq (NET_RX), and the kernel's TCP stack updates the sliding window, sends an ACK, and stages bytes into the socket queue. Even if an Nginx worker process pauses for 30 milliseconds, the kernel continues servicing TCP ACKs in the background. The sender's congestion window may throttle slightly, but the connection remains intact. HTTP/3 reverses this design completely. QUIC lives on top of UDP, running entirely in user space inside the Nginx worker process (ngx_http_v3_module). The Linux kernel has no semantic understanding of QUIC frames, sequence numbers, or ACK frames. It simply routes incoming UDP datagrams into the socket's receive ring buffer. If the Nginx event loop fails to drain this queue via epoll_wait and recvmsg (or recvmmsg), the bounded socket queue (rmem) fills up in milliseconds. The moment the buffer hits its limit, the kernel's udp_queue_rcv_skb function silently drops incoming packets, incrementing RcvbufErrors in /proc/net/snmp. Because UDP lacks TCP's immediate hardware/kernel acknowledgment loop, dropped packets trigger timeout-based retransmissions from the client. If packet loss persists across multiple RTTs, the browser assumes the UDP path is poisoned or broken and permanently degrades the session to TCP-based HTTP/2.
Nginx relies on an event-driven, non-blocking architecture where a small pool of single-threaded worker processes handles tens of thousands of concurrent connections. This model assumes that no operation in the event loop ever yields to synchronous blocking operations. However, disk I/O breaks this assumption when proxy caching (proxy_cache) is active. When Nginx proxies an asset from an upstream service and writes it to disk, it executes standard POSIX write() syscalls. In Linux, standard file writes do not write immediately to NVMe or SSD storage; they append data to the kernel page cache and mark the pages as dirty. Under normal circumstances, this takes single-digit microseconds. But when high concurrency pairs with large responses, dirty pages accumulate rapidly. Once the volume of dirty memory exceeds the kernel threshold defined by vm.dirty_background_ratio, the kernel wakes the bdi-default or kworker flush threads to write pages out to physical blocks. If dirty memory generation outpaces physical storage throughput and breaches vm.dirty_ratio (or vm.dirty_bytes), the Linux kernel shifts into synchronous throttling. Any process calling write() is forced into an uninterruptible sleep state (task state D) to flush dirty blocks itself before continuing. Even without crossing vm.dirty_ratio, standard filesystem operations can block on inode mutexes, journaling transactions (especially on ext4 or XFS during extent allocation), or local block layer congestion. For a brief window—often between 15 and 80 milliseconds—the Nginx worker process is completely frozen.
A 50-millisecond pause on an event thread might seem negligible for legacy web traffic, but for high-throughput UDP sockets, it is catastrophic. Consider an Nginx worker serving an aggregated ingress rate of 200 Mbps of HTTP/3 traffic across thousands of multiplexed streams. That translates to roughly 17,000 incoming datagrams per second, or approximately 850 datagrams during a 50-millisecond disk write stall. If the default Linux UDP receive buffer (net.core.rmem_default) is configured at its common default of 212,992 bytes (around 208 KB), a standard datagram payload sized near the MTU limit (around 1,400 bytes) will consume roughly 2.3 KB of kernel memory per sk_buff due to internal metadata overhead. The buffer can hold fewer than 100 packets before it is saturated:
BASH
# Check kernel UDP socket drop counters
cat /proc/net/snmp | grep -E 'Udp:' | awk '{print "InDatagrams: " 2, "RcvbufErrors: "4}'
# Inspect live per-socket queue backlogs
ss -nump --udp state established '( sport = :443 )'
When the Nginx worker wakes from its disk-induced slumber, it encounters an empty queue because hundreds of critical QUIC packets—including ACK ranges, stream offset segments, and handshake frames—have already been discarded by the network stack. The client-side QUIC congestion controller misinterprets this burst loss as severe network congestion, collapses its congestion window to minimum, and repeatedly attempts 0-RTT/1-RTT retransmissions. If packet loss occurs during stream initialization or connection handshake, browsers like Chrome and Firefox trigger an immediate fallback to HTTP/2 over TCP.
Eliminating these silent packet drops requires severing the execution dependency between disk writeback and the network event loop. Production systems running HTTP/3 with heavy proxy caching must implement a multi-layered defensive configuration across both application and kernel parameters. First, disk operations must be evicted from the primary event thread using thread pools. Nginx provides asynchronous disk writing via thread pools, ensuring that write operations run inside separate operating system threads, allowing the worker loop to service epoll without delay.
NGINX
# Distribute disk write operations off the main epoll event loop
aio threads=default;
aio_write on;

# Enable socket reuse to distribute UDP datagrams across multiple workers
listen 443 quic reuseport;
Second, the kernel's UDP buffer ceilings and writeback triggers must be tuned to handle transient bursts without stalling processes or discarding frames. The writeback triggers should begin flushing in the background early, preventing sudden synchronous stalls, while UDP buffers must provide adequate breathing room during unavoidable CPU scheduling spikes.
BASH
# sysctl.conf: Tune dirty writeback and socket ring buffers
vm.dirty_background_ratio = 5
vm.dirty_ratio = 10
net.core.rmem_default = 2097152
net.core.rmem_max = 16777216
Finally, avoid co-locating proxy cache directories on the root OS filesystem. Placing the proxy_cache_path on a dedicated NVMe mount formatted with noatime, decoupled from system logs and database writes, prevents filesystem lock contention from stalling workers. HTTP/3 moves transport engineering directly into user-space; keeping those user-space sockets alive means ensuring that storage hardware limitations never block your network execution loops.