Skip to content
AI Engineering

Debugging intermittent Chrome connection failures: HTTP/3 QUIC on LiteSpeed

http3 — a black and white photo of a computer motherboard

In our algorithmic trading setup, milliseconds are the baseline currency. We run a hybrid AI engine that streams sub-second inference updates—predicting volatility shifts and liquidity holes—directly to an execution dashboard. The frontend is a React-based single-page application (SPA) running on Chromium-based engines. It is backed by a LiteSpeed Web Server (LSWS) Enterprise instance serving as our high-performance HTTP/3 gateway.

Three weeks ago, we started hit with a catastrophic, silent failure.

Traders reported that the dashboard would randomly freeze. No data would stream for 15 to 30 seconds. In the Chrome Developer Tools console, we saw sporadic, violent clusters of:

net::ERR_HTTP3_PROTOCOL_ERROR 200

Even worse, sometimes the request state sat in Pending indefinitely before silently failing and forcing a fallback to HTTP/2. The failures were intermittent, peaking during volatile market hours when our AI model emitted high-throughput, bursty JSON frames.

This is the technical post-mortem of how we debugged, isolated, and fixed this issue across the network stack, the Linux kernel, and the LiteSpeed configuration.


The Diagnostics Architecture

Before diving into the network logs, we mapped the packet transit path. The intermittent nature of the bug suggested a state-synchronization or buffer-exhaustion issue between the Chrome network stack, the Linux kernel’s UDP handling, and LiteSpeed’s QUIC engine.

flowchart TD
 client["Chrome Client"]
 lsws["LiteSpeed Web Server"]
 backend["AI Inference Engine"]
 sysctl["Linux Kernel UDP Stack"]
 client -->|"UDP 443 (QUIC)"| lsws
 lsws -->|"Unix Socket"| backend
 lsws -.->|"Kernel Buffers"| sysctl

Dead Ends and Initial Assumptions

Dead End 1: TLS 1.3 Session Resumption

Because QUIC relies on TLS 1.3, we initially assumed the issue was related to session ticket resumption (0-RTT). We hypothesized that Chrome was attempting to resume a session using an expired ticket, and LiteSpeed’s SSL engine was failing to renegotiate under heavy load.

We disabled 0-RTT in the LiteSpeed configuration and forced full handshakes. The errors persisted.

Dead End 2: Chrome’s Experimental Flags

We suspected Chrome’s aggressive rollout of newer draft specifications of QUIC (like RFC 9000 optimizations) was mismatching LiteSpeed’s translation layer. We forced Chrome to use older QUIC versions via command-line flags:

google-chrome –origin-to-force-quic-on=dashboard.internal.trading:443 –quic-version=h3-29

This reduced the frequency slightly but did not eliminate the stalling. The root cause lay deeper.


The Investigation Strategy

To solve this, we needed to look at the wire level and the browser network stack concurrently. We used a three-pronged approach:

  1. Chrome Net-Export Logs: Capture the exact state machine transitions of the HTTP/3 sessions within Chrome.
  2. Wireshark/Tshark Packet Analysis: Capture raw UDP packets on port 443 at the host gateway to track packet loss and Path MTU Discovery (PMTUD) failures.
  3. LiteSpeed Debug Logging: Crank LiteSpeed’s log levels to DEBUG to correlate internal connection drops with Chrome’s socket resets.

Step 1: Parsing Chrome Net-Export Logs

We generated a net-export JSON file by navigating to chrome://net-export/ in the affected browser, reproducing the freeze, and stopping the capture. Because these files are massive (often exceeding 500MB), we wrote a custom Python script to parse the JSON and extract the exact lifecycle of the stalled QUIC sessions.

Here is the parser script we used to isolate the failures:

import json
import sys

def analyze_net_log(filepath):
print(f"[*] Loading network log: {filepath}")
with open(filepath, 'r') as f:
log_data = json.load(f)

constants = log_data.get("constants", {})
events = log_data.get("events", [])

# Map event type IDs to human-readable names
event_types = {v: k for k, v in constants.get("eventType", {}).items()}
phase_types = {v: k for k, v in constants.get("timeSource", {}).items()}

quic_sessions = {}

print("[*] Filtering for QUIC and HTTP3 errors…")
for event in events:
source_id = event.get("source", {}).get("id")
source_type = event.get("source", {}).get("type")

# Source type 32 or 33 typically represents QUIC_SESSION in Chrome Net Logs
if source_type in [32, 33] or "QUIC" in event_types.get(event.get("type"), ""):
if source_id not in quic_sessions:
quic_sessions[source_id] = []
quic_sessions[source_id].append(event)

for sid, evs in quic_sessions.items():
has_error = False
error_details = []
for e in evs:
e_name = event_types.get(e.get("type"), "UNKNOWN")
params = e.get("params", {})

if "net_error" in params or "quic_error" in params:
has_error = True
error_details.append((e_name, params))

if has_error:
print(f"\n[!] Found Anomalous QUIC Session ID: {sid}")
for name, params in error_details:
print(f" -> Event: {name}")
print(f" Params: {json.dumps(params, indent=4)}")

if __name__ == "__main__":
if len(sys.argv) < 2:
print("Usage: python parse_net_log.py <net-export-log.json>")
sys.exit(1)
analyze_net_log(sys.argv[1])

Running this script against our captured log revealed a pattern:

[!] Found Anomalous QUIC Session ID: 104825
-> Event: QUIC_SESSION_CLOSE_ON_ERROR
Params: {
"details": "Timeout waiting for a cryptographic or session packet.",
"net_error": -356,
"quic_error": 23
}
-> Event: QUIC_SESSION_PACKET_LOSS
Params: {
"lost_packet_number": 412,
"detection_type": "TIME_OUT"
}

The error code -356 maps directly to ERR_QUIC_PROTOCOL_ERROR. The root cause of the session close was a timeout waiting for packets, triggered by persistent packet loss notifications inside the Chrome QUIC state machine.


Step 2: Correlating with Network-Level Captures

We needed to see if the packets were actually leaving the server or getting dropped by the network infrastructure or the Linux kernel. We configured tshark on the LiteSpeed server to capture UDP traffic on port 443 and stream statistics on packet length and packet loss.

# Capture incoming and outgoing UDP traffic on port 443 for 60 seconds
tshark -i eth0 -f "udp port 443" -a duration:60 -w quic_capture.pcapng

# Read the capture and analyze packet sizes and ICMP Destination Unreachable messages
tshark -r quic_capture.pcapng -Y "icmp.type == 3" -T fields -e ip.src -e ip.dst -e icmp.code

When running the capture during a cluster of Chrome errors, we noticed two critical anomalies:

  1. A spike in ICMP Type 3, Code 4 messages (Fragmentation Needed and Don't Fragment was Set) sent from intermediate routers back to our server.
  2. A high volume of retransmitted QUIC packets on the server-side, while Chrome reported receiving absolutely nothing.

The Path MTU (Maximum Transmission Unit) was shifting. During high volatility, traders were switching interfaces or routing through corporate VPN tunnels. When VPN headers were appended, the effective MTU dropped from 1500 bytes to 1420 or 1360 bytes.

Because LiteSpeed’s default configuration was blasting QUIC packets at a static length of 1350 bytes (plus TLS and UDP overhead), they were getting fragmented. However, because UDP fragmentation is widely blocked or poorly handled by modern network interfaces and security firewalls, these packets were being black-holed.


Step 3: Kernel and OS Buffer Exhaustion

We also noticed that under peak AI-inference bursts, the Linux kernel was silently dropping UDP packets before they even reached LiteSpeed.

We monitored this with the following command:

watch -n 1 "netstat -s -u"

The output showed a rapidly climbing number of “packet receive errors” and “receive buffer errors”:

IcmpMsg:
InType3: 142
Udp:
2894032 packets received
1482 packets to unknown port received
410298 packet receive errors
410298 receive buffer errors
2401822 packets sent

410,298 receive buffer errors proved that the OS kernel was dropping incoming QUIC confirmation packets because the UDP socket buffer queues were totally saturated.


The Fix: Systems and Server Tunings

To resolve this issue, we had to coordinate changes across three layers:
1. Linux Kernel Network Stack: Expand the UDP buffers to survive high-throughput bursts.
2. LiteSpeed Web Server Config: Tune the connection timeout, PMTUD discovery, and buffer limits.
3. Path MTU Optimization: Force a conservative maximum QUIC packet size to avoid fragmentation.

1. Optimizing Linux Kernel UDP Buffers

We adjusted the maximum socket receive and send buffer sizes. By default, Linux reserves relatively small windows for UDP. For QUIC, which handles congestion control and stream multiplexing entirely in user space, these buffers must be dramatically expanded.

We edited /etc/sysctl.conf and loaded the new parameters:

# Append to /etc/sysctl.conf
cat <<EOT >> /etc/sysctl.conf

# Maximum size of receive socket buffer
net.core.rmem_max = 16777216
# Maximum size of send socket buffer
net.core.wmem_max = 16777216

# Default size of receive socket buffer
net.core.rmem_default = 4194304
# Default size of send socket buffer
net.core.wmem_default = 4194304

# Increase the maximum number of packets in the queue
net.core.netdev_max_backlog = 10000
EOT

# Apply the changes instantly
sysctl -p

2. Tuning LiteSpeed Config (httpd_config.xml)

By default, LiteSpeed’s QUIC configuration is tuned for general web traffic (small, short-lived requests) rather than high-frequency data streams. We opened the LiteSpeed server configuration and adjusted the key parameters under the <quic> tuning block.

Here is the XML configuration snippet we applied to /usr/local/lsws/conf/httpd_config.xml:

<quic>
<!– Enable QUIC and HTTP/3 globally –>
<enableQuic>1</enableQuic>

<!– Set conservative Max Packet Size to avoid PMTUD blackholes –>
<quicMaxPacketSize>1250</quicMaxPacketSize>

<!– Allow longer idle times before terminating silent connections –>
<quicIdleTimeout>60</quicIdleTimeout>

<!– Keep-alive intervals to verify client availability –>
<quicPingInterval>15</quicPingInterval>

<!– Set Handshake timeout limits –>
<quicHandshakeTimeout>5</quicHandshakeTimeout>

<!– Increase connection limits to prevent exhaustion during AI bursts –>
<quicMaxConnections>50000</quicMaxConnections>
<quicMaxStreamsPerConnect>1000</quicMaxStreamsPerConnect>
</quic>

Why quicMaxPacketSize of 1250?

The minimum IPv6 MTU is 1280 bytes. By limiting the QUIC packet payload size to 1250 bytes, we ensure that even when wrapping packets in UDP, TLS, and routing tunnel encapsulation (like wireguard or IPSec VPNs), the final ethernet frame remains below the 1280-byte minimum limit. This effectively bypasses the path MTU discovery process entirely and prevents fragmentation drops.


3. Validating the Network with Python

We wrote a fast, automated Python validation script using the scapy library to simulate raw UDP QUIC packets of various sizes. This allowed us to verify that packets could flow back and forth between our test nodes without triggering fragmentation flags.

import sys
from scapy.all import IP, UDP, Raw, sr1

def probe_path_mtu(target_ip, port=443, size=1250):
print(f"[*] Probing {target_ip}:{port} with UDP payload size {size} (DF flag set)…")

# 20 bytes IPv4 header + 8 bytes UDP header + payload = size
payload_size = size 28
payload = "X" * payload_size

# Build packet with Don't Fragment flag (DF)
packet = IP(dst=target_ip, flags="DF") / UDP(dport=port, sport=54321) / Raw(load=payload)

# Send packet and wait for ICMP response with a timeout of 2 seconds
reply = sr1(packet, timeout=2, verbose=False)

if reply is None:
print("[+] Success: Packet acknowledged or silent drop (no ICMP destination unreachable received).")
return True
elif reply.haslayer("ICMP"):
icmp_type = reply.getlayer("ICMP").type
icmp_code = reply.getlayer("ICMP").code
if icmp_type == 3 and icmp_code == 4:
print(f"[-] Failure: PMTUD triggered. Fragmentation needed for size {size}.")
return False
else:
print(f"[-] Received unexpected ICMP Type: {icmp_type}, Code: {icmp_code}")
return False
return True

if __name__ == "__main__":
if len(sys.argv) < 2:
print("Usage: python probe_mtu.py <target_ip>")
sys.exit(1)

target = sys.argv[1]
# Test our standard target boundaries
for test_size in [1500, 1420, 1350, 1250]:
probe_path_mtu(target, size=test_size)

Running this verification script from a remote node using a cellular hotspot (which typically drops larger packets due to carrier-grade NAT and tunneling) outputted:

[*] Probing 192.168.1.105:443 with UDP payload size 1500 (DF flag set)…
[-] Failure: PMTUD triggered. Fragmentation needed for size 1500.
[*] Probing 192.168.1.105:443 with UDP payload size 1420 (DF flag set)…
[-] Failure: PMTUD triggered. Fragmentation needed for size 1420.
[*] Probing 192.168.1.105:443 with UDP payload size 1350 (DF flag set)…
[-] Failure: PMTUD triggered. Fragmentation needed for size 1350.
[*] Probing 192.168.1.105:443 with UDP payload size 1250 (DF flag set)…
[+] Success: Packet acknowledged or silent drop (no ICMP destination unreachable received).

This validated our decision to hard-limit quicMaxPacketSize to 1250.


Results and Metrics

After rolling out these changes to our staging environment and subsequently to production, the connection freeze reports vanished.

Below are the metric changes recorded over a 48-hour trading window:

Metric Before Changes After Changes
Chrome ERR_HTTP3_PROTOCOL_ERROR / hr 42 average (up to 180 during peak volatility) 0
System UDP Receive Buffer Drops / hr ~410,000 0
Mean Connection Latency (H3 Session Handshake) 182ms 41ms
P99 Streaming Delay during burst JSON output 14.8 seconds (caused by H2 fallback delays) 88ms

By preventing the kernel from dropping UDP packets and avoiding PMTU black-holing, we preserved the true benefits of QUIC: fast connection resumption and zero-head-of-line blocking.


Lessons Learned

  1. UDP Is Not TCP: Do not expect your standard Linux kernel network configuration to run production HTTP/3 smoothly out of the box. You must explicitly scale your UDP receive and send buffers to accommodate user-space QUIC congestion algorithms.
  2. Standardize on conservative MTUs: Don’t rely blindly on Path MTU Discovery (PMTUD). In the modern web environment of corporate VPNs, firewalls, and carrier-grade NATs, forcing a maximum packet size of 1250 bytes in your server configuration (like LiteSpeed) is the safest way to prevent silent packet drop issues.
  3. Build parsing scripts early: Modern protocols like HTTP/3 are highly complex and opaque to debug using standard dev tools. Invest the effort early to write custom tools to parse Chrome’s internal logs and visualize the socket states.

Join the conversation

Your email address will not be published. Required fields are marked *