{"schemaVersion":1,"id":"https://lucioduran.com/blog/tcp-quic-http3-kernel-bypass-performance","url":"https://lucioduran.com/blog/tcp-quic-http3-kernel-bypass-performance","title":"TCP vs QUIC vs HTTP/3: Kernel Bypass, Zero-Copy, and Latency Optimization","description":"Real benchmarks comparing TCP, QUIC, and HTTP/3 with io_uring, DPDK, and zero-copy networking. Congestion control algorithms dissected with actual latency measurements from production.","language":"en","datePublished":"2025-12-07","dateModified":null,"contentFormat":"text/markdown","content":"Networking\n\n# TCP vs QUIC vs HTTP/3: Kernel Bypass, Zero-Copy, and Latency Optimization\n\n2025-12-078 min read\n\nLucio Durán\nEngineering Manager & AI Solutions Architect\n\nAlso available in: [Español](<https://lucioduran.com/es/blog/tcp-quic-http3-kernel-bypass-performance>)\n\n## The testing setup\n\nBefore examining the numbers, it is worth being transparent about the hardware, too many \"benchmark\" posts are run on a random EC2 instance with noisy neighbors.\n\n- **Server**: Bare metal, AMD EPYC 9454 (48C/96T), 256GB DDR5-4800\n- **NIC**: Mellanox ConnectX-7 (100GbE), dual-port\n- **Client**: Same hardware, direct connection via DAC cable (no switch)\n- **OS**: Ubuntu 24.04 LTS, kernel 6.8.0\n- **DPDK**: 23.11 LTS\n- **QUIC**: quiche 0.21 (Cloudflare's implementation) and msquic 2.3\n- **io\\_uring**: liburing 2.6\n\nEach test ran for 60 seconds, repeated 10 times, with the median taken. CPU frequency scaling? Disabled. Turbo boost? Disabled. IRQ affinity? Pinned to specific cores. Hyperthreading? Off. No shortcuts.\n\n## TCP: the old guard still has teeth\n\nPlain TCP serves as the baseline everyone compares against, and its performance with modern kernels is frequently underestimated.\n\nStandard ` send() `/` recv() ` over TCP with default kernel settings:\n\n```c\n// Classic blocking TCP, nothing fancy\nint sock = socket(AF_INET, SOCK_STREAM, 0);\nsetsockopt(sock, IPPROTO_TCP, TCP_NODELAY, &one, sizeof(one));\nconnect(sock, (struct sockaddr*)&addr, sizeof(addr));\n\n// 64KB messages, 1M iterations\nfor (int i = 0; i < 1000000; i++) {\n send(sock, buf, 65536, 0);\n}\n```\n\n**Results**: 14.2 Gbps throughput, 11.3us mean latency, 18.7us p99.\n\nReasonable, but substantial performance remains untapped. The problem is that every ` send() ` call is a syscall, context switch into kernel mode, copy the buffer, context switch back.\n\n### TCP + io\\_uring: zero-copy sends\n\n[io\\_uring](<https://github.com/axboe/liburing>) with ` IORING_OP_SEND_ZC ` eliminates the buffer copy:\n\n```c\nstruct io_uring ring;\nio_uring_queue_init(256, &ring, 0);\n\nstruct io_uring_sqe *sqe = io_uring_get_sqe(&ring);\nio_uring_prep_send_zc(sqe, sock, buf, 65536, 0, 0);\nsqe->user_data = 1;\nio_uring_submit(&ring);\n\n// Wait for completion AND notification that buffer is safe to reuse\nstruct io_uring_cqe *cqe;\nio_uring_wait_cqe(&ring, &cqe);\nif (cqe->flags & IORING_CQE_F_NOTIF) {\n // Buffer can be reused now\n}\nio_uring_cqe_seen(&ring, cqe);\n```\n\n**Results**: 31.7 Gbps throughput, 5.8us mean latency, 9.2us p99.\n\nThat's a 2.2x throughput improvement and ~50% latency reduction. The key insight: it's not just about avoiding the copy, batching submissions into the ring means fewer syscalls overall. One ` io_uring_submit() ` can push dozens of operations.\n\n### TCP + DPDK: full kernel bypass\n\nWhen you absolutely, positively need every last nanosecond:\n\n```c\n// DPDK packet construction, you own the entire stack\nstruct rte_mbuf *pkt = rte_pktmbuf_alloc(mbuf_pool);\nstruct rte_ether_hdr *eth = rte_pktmbuf_mtod(pkt, struct rte_ether_hdr*);\n// ... build ethernet, IP, TCP headers manually ...\n\n// Direct NIC access, no kernel involvement\nuint16_t nb_tx = rte_eth_tx_burst(port_id, queue_id, &pkt, 1);\n```\n\n**Results**: 82.4 Gbps throughput, 1.1us mean latency, 1.8us p99.\n\nThis is the result of removing the kernel entirely. The cost is significant: TCP must be reimplemented in userspace. In practice, this approach is reserved for specific high-frequency trading workloads, and the debugging experience requires considerable expertise.\n\n## QUIC: not just \"TCP 2.0\"\n\nQUIC is frequently characterized as \"the future of networking,\" but the reality is more nuanced. It runs in userspace, which means no kernel bypass tricks, and it mandates TLS 1.3, so every packet carries encryption overhead.\n\nThe test used [Cloudflare](<https://www.cloudflare.com/>)'s [quiche](<https://github.com/cloudflare/quiche>) implementation, which is extensively proven in production:\n\n```rust\n// quiche QUIC connection setup\nlet mut config = quiche::Config::new(quiche::PROTOCOL_VERSION)?;\nconfig.set_application_protos(b\"\\x02h3\")?;\nconfig.set_max_idle_timeout(30000);\nconfig.set_initial_max_data(10_000_000);\nconfig.set_initial_max_stream_data_bidi_local(1_000_000);\nconfig.enable_early_data();\n\nlet conn = quiche::connect(Some(\"server\"), &scid, local, peer, &mut config)?;\n\n// Send data on a stream\nlet written = conn.stream_send(stream_id, &buf[..65536], false)?;\n```\n\n**Results**: 8.7 Gbps throughput, 22.4us mean latency, 38.1us p99.\n\nSlower than plain TCP? Yes. And that is on a direct connection with zero packet loss. However, QUIC was not designed to win on this benchmark. It was designed for the real internet, where packets get dropped, connections migrate between networks, and head-of-line blocking kills multiplexed HTTP/2 streams.\n\n### Where QUIC actually wins\n\nA tc netem profile simulating a typical mobile connection was configured: 50ms RTT, 1% packet loss, 0.5% reordering.\n\n| Protocol | Throughput | p99 Latency | Connection Setup |\n| --- | --- | --- | --- |\n| TCP + TLS 1.3 | 2.1 Gbps | 187ms | 3 RTT (SYN + TLS) |\n| QUIC | 3.8 Gbps | 112ms | 1 RTT (0-RTT resume) |\n| HTTP/3 | 3.6 Gbps | 98ms\\* | 0 RTT (resume) |\n\n\\*HTTP/3's p99 is lower because independent streams avoid HOL blocking.\n\nOn lossy networks, QUIC's 0-RTT connection resumption and independent stream multiplexing absolutely destroy TCP. The 0-RTT case is particularly brutal for TCP, by the time TCP finishes its three-way handshake and TLS negotiation, QUIC has already sent the first response.\n\n## Congestion control: the algorithm matters more than the protocol\n\nThis aspect is frequently overlooked. The choice of congestion control algorithm has more impact on real-world performance than TCP vs QUIC in most scenarios.\n\nFour algorithms were tested on a 100ms RTT link with varying packet loss:\n\n```bash\n# Switching congestion control on Linux\nsysctl -w net.ipv4.tcp_congestion_control=bbr\n# or cubic, reno, bbr3 (requires patched kernel)\n```\n\n### BBRv3 vs CUBIC vs Reno, 100ms RTT\n\nAt 0.1% loss: BBRv3 pushes 42 Gbps vs CUBIC's 28 Gbps. BBR's model-based approach doesn't back off as aggressively.\n\nAt 1% loss: BBRv3 hits 18 Gbps, CUBIC drops to 8.3 Gbps. BBR is clearly superior here.\n\nAt 5% loss: Here's the surprise, CUBIC recovers to 3.1 Gbps while BBRv3 only manages 2.4 Gbps. BBR's bandwidth probing gets confused by sustained high loss rates and oscillates between probing and draining.\n\nInvestigation into why BBRv3 underperforms at high loss rates reveals an interesting finding. The answer is in how BBR estimates the bottleneck bandwidth. It uses a windowed max filter over recent delivery rates, but at 5% loss, the ACK stream becomes so noisy that the max filter keeps picking up outlier measurements, causing the sending rate to oscillate wildly.\n\n```python\n# Simplified BBR bandwidth estimation\nclass BBRBandwidthFilter:\n def __init__(self, window_size=10):\n self.window = deque(maxlen=window_size)\n\n def update(self, delivery_rate):\n self.window.append(delivery_rate)\n # At high loss, these samples are all over the place\n return max(self.window) # This max is the problem\n```\n\n## HTTP/3: the full picture\n\nHTTP/3 is QUIC with HTTP semantics on top. The performance characteristics inherit from QUIC, but there's an additional layer worth discussing: QPACK header compression.\n\nQPACK is HTTP/3's answer to HPACK, but it's designed for QUIC's out-of-order delivery. This means it can't use the simple sequential state machine that HPACK uses. Instead, it has encoder and decoder streams that operate independently:\n\n```\n// QPACK dynamic table update\nEncoder Stream: INSERT name=\":status\" value=\"200\"\n INSERT name=\"content-type\" value=\"application/json\"\n\n// Header block references dynamic table entries\nRequest Stream: INDEXED ref=0 (\":status: 200\")\n INDEXED ref=1 (\"content-type: application/json\")\n```\n\nIn practice, QPACK achieves 5-15% worse compression than HPACK on typical web traffic (because it can't risk blocking), but the elimination of head-of-line blocking more than compensates.\n\n## The practical decision framework\n\nAfter all these benchmarks, here is a practical recommendation framework:\n\n**Use plain TCP + io\\_uring** when you control both endpoints, the network is reliable, and you need raw throughput. Think: inter-datacenter replication, database wire protocols, service mesh data planes.\n\n**Use QUIC** when clients are on unreliable networks (mobile, WiFi), you need connection migration, or multiplexing multiple independent streams. Think: client-facing APIs, real-time communication, CDN edge.\n\n**Use DPDK** only, and I mean only, when you need sub-2us latency and you have a team that can maintain a custom networking stack. Think: HFT, telco packet processing, DPI appliances.\n\n**Avoid HTTP/3** unless there is a clear requirement. If the clients are browsers, they will negotiate it automatically via Alt-Svc headers. If the clients are controlled services, the HTTP framing overhead is likely not justified, use QUIC directly or remain with TCP.\n\n## One more thing: GSO and GRO\n\nGeneric Segmentation Offload and Generic Receive Offload are the unsung heroes of modern networking performance. They let you hand the NIC a 64KB buffer and let hardware split it into MTU-sized segments:\n\n```c\n// Enable GSO on a UDP socket (for QUIC)\nint gso_size = 1200; // QUIC packet size\nsetsockopt(fd, SOL_UDP, UDP_SEGMENT, &gso_size, sizeof(gso_size));\n\n// Now you can send 64KB in a single syscall\n// The kernel/NIC splits it into 1200-byte UDP packets\nsendmsg(fd, &msg, 0); // msg contains 64KB buffer\n```\n\nThis single optimization can improve QUIC throughput by 3.2x. Without GSO, each QUIC packet requires a separate ` sendmsg() ` call. With GSO, you batch 50+ packets into one syscall. This is how Cloudflare gets QUIC to perform at scale.\n\nThe networking landscape is shifting fast. io\\_uring is getting new zero-copy features every kernel release, QUIC implementations are maturing rapidly, and kernel bypass is becoming more accessible. But the fundamentals have not changed: measure the actual workload, on the actual network, with the actual hardware. Synthetic benchmarks are a starting point, not an answer.\n\n## Frequently asked questions\n\n### Is QUIC always faster than TCP?\n\nNo. On low-latency, low-loss networks, TCP with kernel bypass (io\\_uring or DPDK) often matches or beats QUIC because QUIC runs in userspace and carries encryption overhead. QUIC shines on lossy, high-latency connections and for connection migration scenarios.\n\n### What is kernel bypass and when should I use it?\n\nKernel bypass lets your application send and receive packets without going through the OS networking stack. Technologies like DPDK and io\\_uring reduce context switches and memory copies. Use it when you need sub-millisecond latency or are pushing millions of packets per second.\n\n### How does zero-copy networking work with io\\_uring?\n\nio\\_uring's IORING\\_OP\\_SEND\\_ZC operation passes buffer references to the kernel instead of copying data. The kernel DMAs directly from your application buffer to the NIC. You get a completion event when the buffer is safe to reuse.\n\n## Tips\n\n1. **Tip:** Use ` io_uring ` with ` IORING_OP_SEND_ZC ` for zero-copy sends, it cuts syscall overhead by ~40% on high-throughput TCP workloads.\n2. **Tip:** QUIC's 0-RTT resumption significantly reduces latency for mobile clients, but it is vulnerable to replay attacks, always validate ` early_data ` at the application layer.\n3. **Tip:** When benchmarking with DPDK, pin your threads to isolated cores using ` isolcpus ` and disable IRQ balancing, otherwise your numbers are meaningless noise.\n4. **Tip:** BBRv3 is not a silver bullet. On lossy links with \\>2% packet loss, CUBIC still outperforms it. Profile your actual network conditions before switching.\n\n- tcp\n- quic\n- http3\n- io\\_uring\n- dpdk\n- zero-copy\n- kernel-bypass\n- networking\n- performance\n- congestion-control\n\n[Back to Blog](<https://lucioduran.com/blog>)","headings":[{"level":1,"text":"TCP vs QUIC vs HTTP/3: Kernel Bypass, Zero-Copy, and Latency Optimization","url":"https://lucioduran.com/blog/tcp-quic-http3-kernel-bypass-performance#title"},{"level":2,"text":"The testing setup","url":"https://lucioduran.com/blog/tcp-quic-http3-kernel-bypass-performance#the-testing-setup"},{"level":2,"text":"TCP: the old guard still has teeth","url":"https://lucioduran.com/blog/tcp-quic-http3-kernel-bypass-performance#tcp-the-old-guard-still-has-teeth"},{"level":3,"text":"TCP + io_uring: zero-copy sends","url":"https://lucioduran.com/blog/tcp-quic-http3-kernel-bypass-performance#tcp-iouring-zero-copy-sends"},{"level":3,"text":"TCP + DPDK: full kernel bypass","url":"https://lucioduran.com/blog/tcp-quic-http3-kernel-bypass-performance#tcp-dpdk-full-kernel-bypass"},{"level":2,"text":"QUIC: not just \"TCP 2.0\"","url":"https://lucioduran.com/blog/tcp-quic-http3-kernel-bypass-performance#quic-not-just-tcp-20"},{"level":3,"text":"Where QUIC actually wins","url":"https://lucioduran.com/blog/tcp-quic-http3-kernel-bypass-performance#where-quic-actually-wins"},{"level":2,"text":"Congestion control: the algorithm matters more than the protocol","url":"https://lucioduran.com/blog/tcp-quic-http3-kernel-bypass-performance#congestion-control-the-algorithm-matters-more-than-the-protocol"},{"level":3,"text":"BBRv3 vs CUBIC vs Reno, 100ms RTT","url":"https://lucioduran.com/blog/tcp-quic-http3-kernel-bypass-performance#bbrv3-vs-cubic-vs-reno-100ms-rtt"},{"level":2,"text":"HTTP/3: the full picture","url":"https://lucioduran.com/blog/tcp-quic-http3-kernel-bypass-performance#http3-the-full-picture"},{"level":2,"text":"The practical decision framework","url":"https://lucioduran.com/blog/tcp-quic-http3-kernel-bypass-performance#the-practical-decision-framework"},{"level":2,"text":"One more thing: GSO and GRO","url":"https://lucioduran.com/blog/tcp-quic-http3-kernel-bypass-performance#one-more-thing-gso-and-gro"},{"level":2,"text":"Frequently asked questions","url":"https://lucioduran.com/blog/tcp-quic-http3-kernel-bypass-performance#questions"},{"level":3,"text":"Is QUIC always faster than TCP?","url":"https://lucioduran.com/blog/tcp-quic-http3-kernel-bypass-performance#question-1"},{"level":3,"text":"What is kernel bypass and when should I use it?","url":"https://lucioduran.com/blog/tcp-quic-http3-kernel-bypass-performance#question-2"},{"level":3,"text":"How does zero-copy networking work with io_uring?","url":"https://lucioduran.com/blog/tcp-quic-http3-kernel-bypass-performance#question-3"},{"level":2,"text":"Tips","url":"https://lucioduran.com/blog/tcp-quic-http3-kernel-bypass-performance#article-tips"}],"sections":[{"heading":"TCP vs QUIC vs HTTP/3: Kernel Bypass, Zero-Copy, and Latency Optimization","level":1,"url":"https://lucioduran.com/blog/tcp-quic-http3-kernel-bypass-performance","content":"Networking"},{"heading":"TCP vs QUIC vs HTTP/3: Kernel Bypass, Zero-Copy, and Latency Optimization","level":1,"url":"https://lucioduran.com/blog/tcp-quic-http3-kernel-bypass-performance#title","content":"2025-12-078 min read\n\nLucio Durán\nEngineering Manager & AI Solutions Architect\n\nAlso available in: [Español](<https://lucioduran.com/es/blog/tcp-quic-http3-kernel-bypass-performance>)"},{"heading":"The testing setup","level":2,"url":"https://lucioduran.com/blog/tcp-quic-http3-kernel-bypass-performance#the-testing-setup","content":"Before examining the numbers, it is worth being transparent about the hardware, too many \"benchmark\" posts are run on a random EC2 instance with noisy neighbors.\n\n- **Server**: Bare metal, AMD EPYC 9454 (48C/96T), 256GB DDR5-4800\n- **NIC**: Mellanox ConnectX-7 (100GbE), dual-port\n- **Client**: Same hardware, direct connection via DAC cable (no switch)\n- **OS**: Ubuntu 24.04 LTS, kernel 6.8.0\n- **DPDK**: 23.11 LTS\n- **QUIC**: quiche 0.21 (Cloudflare's implementation) and msquic 2.3\n- **io\\_uring**: liburing 2.6\n\nEach test ran for 60 seconds, repeated 10 times, with the median taken. CPU frequency scaling? Disabled. Turbo boost? Disabled. IRQ affinity? Pinned to specific cores. Hyperthreading? Off. No shortcuts."},{"heading":"TCP: the old guard still has teeth","level":2,"url":"https://lucioduran.com/blog/tcp-quic-http3-kernel-bypass-performance#tcp-the-old-guard-still-has-teeth","content":"Plain TCP serves as the baseline everyone compares against, and its performance with modern kernels is frequently underestimated.\n\nStandard ` send() `/` recv() ` over TCP with default kernel settings:\n\n```c\n// Classic blocking TCP, nothing fancy\nint sock = socket(AF_INET, SOCK_STREAM, 0);\nsetsockopt(sock, IPPROTO_TCP, TCP_NODELAY, &one, sizeof(one));\nconnect(sock, (struct sockaddr*)&addr, sizeof(addr));\n\n// 64KB messages, 1M iterations\nfor (int i = 0; i < 1000000; i++) {\n send(sock, buf, 65536, 0);\n}\n```\n\n**Results**: 14.2 Gbps throughput, 11.3us mean latency, 18.7us p99.\n\nReasonable, but substantial performance remains untapped. The problem is that every ` send() ` call is a syscall, context switch into kernel mode, copy the buffer, context switch back."},{"heading":"TCP + io_uring: zero-copy sends","level":3,"url":"https://lucioduran.com/blog/tcp-quic-http3-kernel-bypass-performance#tcp-iouring-zero-copy-sends","content":"[io\\_uring](<https://github.com/axboe/liburing>) with ` IORING_OP_SEND_ZC ` eliminates the buffer copy:\n\n```c\nstruct io_uring ring;\nio_uring_queue_init(256, &ring, 0);\n\nstruct io_uring_sqe *sqe = io_uring_get_sqe(&ring);\nio_uring_prep_send_zc(sqe, sock, buf, 65536, 0, 0);\nsqe->user_data = 1;\nio_uring_submit(&ring);\n\n// Wait for completion AND notification that buffer is safe to reuse\nstruct io_uring_cqe *cqe;\nio_uring_wait_cqe(&ring, &cqe);\nif (cqe->flags & IORING_CQE_F_NOTIF) {\n // Buffer can be reused now\n}\nio_uring_cqe_seen(&ring, cqe);\n```\n\n**Results**: 31.7 Gbps throughput, 5.8us mean latency, 9.2us p99.\n\nThat's a 2.2x throughput improvement and ~50% latency reduction. The key insight: it's not just about avoiding the copy, batching submissions into the ring means fewer syscalls overall. One ` io_uring_submit() ` can push dozens of operations."},{"heading":"TCP + DPDK: full kernel bypass","level":3,"url":"https://lucioduran.com/blog/tcp-quic-http3-kernel-bypass-performance#tcp-dpdk-full-kernel-bypass","content":"When you absolutely, positively need every last nanosecond:\n\n```c\n// DPDK packet construction, you own the entire stack\nstruct rte_mbuf *pkt = rte_pktmbuf_alloc(mbuf_pool);\nstruct rte_ether_hdr *eth = rte_pktmbuf_mtod(pkt, struct rte_ether_hdr*);\n// ... build ethernet, IP, TCP headers manually ...\n\n// Direct NIC access, no kernel involvement\nuint16_t nb_tx = rte_eth_tx_burst(port_id, queue_id, &pkt, 1);\n```\n\n**Results**: 82.4 Gbps throughput, 1.1us mean latency, 1.8us p99.\n\nThis is the result of removing the kernel entirely. The cost is significant: TCP must be reimplemented in userspace. In practice, this approach is reserved for specific high-frequency trading workloads, and the debugging experience requires considerable expertise."},{"heading":"QUIC: not just \"TCP 2.0\"","level":2,"url":"https://lucioduran.com/blog/tcp-quic-http3-kernel-bypass-performance#quic-not-just-tcp-20","content":"QUIC is frequently characterized as \"the future of networking,\" but the reality is more nuanced. It runs in userspace, which means no kernel bypass tricks, and it mandates TLS 1.3, so every packet carries encryption overhead.\n\nThe test used [Cloudflare](<https://www.cloudflare.com/>)'s [quiche](<https://github.com/cloudflare/quiche>) implementation, which is extensively proven in production:\n\n```rust\n// quiche QUIC connection setup\nlet mut config = quiche::Config::new(quiche::PROTOCOL_VERSION)?;\nconfig.set_application_protos(b\"\\x02h3\")?;\nconfig.set_max_idle_timeout(30000);\nconfig.set_initial_max_data(10_000_000);\nconfig.set_initial_max_stream_data_bidi_local(1_000_000);\nconfig.enable_early_data();\n\nlet conn = quiche::connect(Some(\"server\"), &scid, local, peer, &mut config)?;\n\n// Send data on a stream\nlet written = conn.stream_send(stream_id, &buf[..65536], false)?;\n```\n\n**Results**: 8.7 Gbps throughput, 22.4us mean latency, 38.1us p99.\n\nSlower than plain TCP? Yes. And that is on a direct connection with zero packet loss. However, QUIC was not designed to win on this benchmark. It was designed for the real internet, where packets get dropped, connections migrate between networks, and head-of-line blocking kills multiplexed HTTP/2 streams."},{"heading":"Where QUIC actually wins","level":3,"url":"https://lucioduran.com/blog/tcp-quic-http3-kernel-bypass-performance#where-quic-actually-wins","content":"A tc netem profile simulating a typical mobile connection was configured: 50ms RTT, 1% packet loss, 0.5% reordering.\n\n| Protocol | Throughput | p99 Latency | Connection Setup |\n| --- | --- | --- | --- |\n| TCP + TLS 1.3 | 2.1 Gbps | 187ms | 3 RTT (SYN + TLS) |\n| QUIC | 3.8 Gbps | 112ms | 1 RTT (0-RTT resume) |\n| HTTP/3 | 3.6 Gbps | 98ms\\* | 0 RTT (resume) |\n\n\\*HTTP/3's p99 is lower because independent streams avoid HOL blocking.\n\nOn lossy networks, QUIC's 0-RTT connection resumption and independent stream multiplexing absolutely destroy TCP. The 0-RTT case is particularly brutal for TCP, by the time TCP finishes its three-way handshake and TLS negotiation, QUIC has already sent the first response."},{"heading":"Congestion control: the algorithm matters more than the protocol","level":2,"url":"https://lucioduran.com/blog/tcp-quic-http3-kernel-bypass-performance#congestion-control-the-algorithm-matters-more-than-the-protocol","content":"This aspect is frequently overlooked. The choice of congestion control algorithm has more impact on real-world performance than TCP vs QUIC in most scenarios.\n\nFour algorithms were tested on a 100ms RTT link with varying packet loss:\n\n```bash\n# Switching congestion control on Linux\nsysctl -w net.ipv4.tcp_congestion_control=bbr\n# or cubic, reno, bbr3 (requires patched kernel)\n```"},{"heading":"BBRv3 vs CUBIC vs Reno, 100ms RTT","level":3,"url":"https://lucioduran.com/blog/tcp-quic-http3-kernel-bypass-performance#bbrv3-vs-cubic-vs-reno-100ms-rtt","content":"At 0.1% loss: BBRv3 pushes 42 Gbps vs CUBIC's 28 Gbps. BBR's model-based approach doesn't back off as aggressively.\n\nAt 1% loss: BBRv3 hits 18 Gbps, CUBIC drops to 8.3 Gbps. BBR is clearly superior here.\n\nAt 5% loss: Here's the surprise, CUBIC recovers to 3.1 Gbps while BBRv3 only manages 2.4 Gbps. BBR's bandwidth probing gets confused by sustained high loss rates and oscillates between probing and draining.\n\nInvestigation into why BBRv3 underperforms at high loss rates reveals an interesting finding. The answer is in how BBR estimates the bottleneck bandwidth. It uses a windowed max filter over recent delivery rates, but at 5% loss, the ACK stream becomes so noisy that the max filter keeps picking up outlier measurements, causing the sending rate to oscillate wildly.\n\n```python\n# Simplified BBR bandwidth estimation\nclass BBRBandwidthFilter:\n def __init__(self, window_size=10):\n self.window = deque(maxlen=window_size)\n\n def update(self, delivery_rate):\n self.window.append(delivery_rate)\n # At high loss, these samples are all over the place\n return max(self.window) # This max is the problem\n```"},{"heading":"HTTP/3: the full picture","level":2,"url":"https://lucioduran.com/blog/tcp-quic-http3-kernel-bypass-performance#http3-the-full-picture","content":"HTTP/3 is QUIC with HTTP semantics on top. The performance characteristics inherit from QUIC, but there's an additional layer worth discussing: QPACK header compression.\n\nQPACK is HTTP/3's answer to HPACK, but it's designed for QUIC's out-of-order delivery. This means it can't use the simple sequential state machine that HPACK uses. Instead, it has encoder and decoder streams that operate independently:\n\n```\n// QPACK dynamic table update\nEncoder Stream: INSERT name=\":status\" value=\"200\"\n INSERT name=\"content-type\" value=\"application/json\"\n\n// Header block references dynamic table entries\nRequest Stream: INDEXED ref=0 (\":status: 200\")\n INDEXED ref=1 (\"content-type: application/json\")\n```\n\nIn practice, QPACK achieves 5-15% worse compression than HPACK on typical web traffic (because it can't risk blocking), but the elimination of head-of-line blocking more than compensates."},{"heading":"The practical decision framework","level":2,"url":"https://lucioduran.com/blog/tcp-quic-http3-kernel-bypass-performance#the-practical-decision-framework","content":"After all these benchmarks, here is a practical recommendation framework:\n\n**Use plain TCP + io\\_uring** when you control both endpoints, the network is reliable, and you need raw throughput. Think: inter-datacenter replication, database wire protocols, service mesh data planes.\n\n**Use QUIC** when clients are on unreliable networks (mobile, WiFi), you need connection migration, or multiplexing multiple independent streams. Think: client-facing APIs, real-time communication, CDN edge.\n\n**Use DPDK** only, and I mean only, when you need sub-2us latency and you have a team that can maintain a custom networking stack. Think: HFT, telco packet processing, DPI appliances.\n\n**Avoid HTTP/3** unless there is a clear requirement. If the clients are browsers, they will negotiate it automatically via Alt-Svc headers. If the clients are controlled services, the HTTP framing overhead is likely not justified, use QUIC directly or remain with TCP."},{"heading":"One more thing: GSO and GRO","level":2,"url":"https://lucioduran.com/blog/tcp-quic-http3-kernel-bypass-performance#one-more-thing-gso-and-gro","content":"Generic Segmentation Offload and Generic Receive Offload are the unsung heroes of modern networking performance. They let you hand the NIC a 64KB buffer and let hardware split it into MTU-sized segments:\n\n```c\n// Enable GSO on a UDP socket (for QUIC)\nint gso_size = 1200; // QUIC packet size\nsetsockopt(fd, SOL_UDP, UDP_SEGMENT, &gso_size, sizeof(gso_size));\n\n// Now you can send 64KB in a single syscall\n// The kernel/NIC splits it into 1200-byte UDP packets\nsendmsg(fd, &msg, 0); // msg contains 64KB buffer\n```\n\nThis single optimization can improve QUIC throughput by 3.2x. Without GSO, each QUIC packet requires a separate ` sendmsg() ` call. With GSO, you batch 50+ packets into one syscall. This is how Cloudflare gets QUIC to perform at scale.\n\nThe networking landscape is shifting fast. io\\_uring is getting new zero-copy features every kernel release, QUIC implementations are maturing rapidly, and kernel bypass is becoming more accessible. But the fundamentals have not changed: measure the actual workload, on the actual network, with the actual hardware. Synthetic benchmarks are a starting point, not an answer."},{"heading":"Frequently asked questions","level":2,"url":"https://lucioduran.com/blog/tcp-quic-http3-kernel-bypass-performance#questions","content":""},{"heading":"Is QUIC always faster than TCP?","level":3,"url":"https://lucioduran.com/blog/tcp-quic-http3-kernel-bypass-performance#question-1","content":"No. On low-latency, low-loss networks, TCP with kernel bypass (io\\_uring or DPDK) often matches or beats QUIC because QUIC runs in userspace and carries encryption overhead. QUIC shines on lossy, high-latency connections and for connection migration scenarios."},{"heading":"What is kernel bypass and when should I use it?","level":3,"url":"https://lucioduran.com/blog/tcp-quic-http3-kernel-bypass-performance#question-2","content":"Kernel bypass lets your application send and receive packets without going through the OS networking stack. Technologies like DPDK and io\\_uring reduce context switches and memory copies. Use it when you need sub-millisecond latency or are pushing millions of packets per second."},{"heading":"How does zero-copy networking work with io_uring?","level":3,"url":"https://lucioduran.com/blog/tcp-quic-http3-kernel-bypass-performance#question-3","content":"io\\_uring's IORING\\_OP\\_SEND\\_ZC operation passes buffer references to the kernel instead of copying data. The kernel DMAs directly from your application buffer to the NIC. You get a completion event when the buffer is safe to reuse."},{"heading":"Tips","level":2,"url":"https://lucioduran.com/blog/tcp-quic-http3-kernel-bypass-performance#article-tips","content":"1. **Tip:** Use ` io_uring ` with ` IORING_OP_SEND_ZC ` for zero-copy sends, it cuts syscall overhead by ~40% on high-throughput TCP workloads.\n2. **Tip:** QUIC's 0-RTT resumption significantly reduces latency for mobile clients, but it is vulnerable to replay attacks, always validate ` early_data ` at the application layer.\n3. **Tip:** When benchmarking with DPDK, pin your threads to isolated cores using ` isolcpus ` and disable IRQ balancing, otherwise your numbers are meaningless noise.\n4. **Tip:** BBRv3 is not a silver bullet. On lossy links with \\>2% packet loss, CUBIC still outperforms it. Profile your actual network conditions before switching.\n\n- tcp\n- quic\n- http3\n- io\\_uring\n- dpdk\n- zero-copy\n- kernel-bypass\n- networking\n- performance\n- congestion-control\n\n[Back to Blog](<https://lucioduran.com/blog>)"}],"links":[{"text":"Español","url":"https://lucioduran.com/es/blog/tcp-quic-http3-kernel-bypass-performance"},{"text":"io_uring","url":"https://github.com/axboe/liburing"},{"text":"Cloudflare","url":"https://www.cloudflare.com/"},{"text":"quiche","url":"https://github.com/cloudflare/quiche"},{"text":"Back to Blog","url":"https://lucioduran.com/blog"}],"structuredData":[{"@context":"https://schema.org","@graph":[{"@type":"BlogPosting","@id":"https://lucioduran.com/blog/tcp-quic-http3-kernel-bypass-performance#article","mainEntityOfPage":{"@type":"WebPage","@id":"https://lucioduran.com/blog/tcp-quic-http3-kernel-bypass-performance"},"headline":"TCP vs QUIC vs HTTP/3: Kernel Bypass, Zero-Copy, and Latency Optimization","description":"Real benchmarks comparing TCP, QUIC, and HTTP/3 with io_uring, DPDK, and zero-copy networking. Congestion control algorithms dissected with actual latency measurements from production.","datePublished":"2025-12-07","author":{"@type":"Person","@id":"https://lucioduran.com/#person","name":"Lucio Durán","url":"https://lucioduran.com"},"publisher":{"@type":"Person","@id":"https://lucioduran.com/#person","name":"Lucio Durán","url":"https://lucioduran.com"},"image":"https://lucioduran.com/favicons/og-default.png","url":"https://lucioduran.com/blog/tcp-quic-http3-kernel-bypass-performance","inLanguage":"en","isPartOf":{"@id":"https://lucioduran.com/blog#blog"},"articleSection":"Networking","keywords":"tcp, quic, http3, io_uring, dpdk, zero-copy, kernel-bypass, networking, performance, congestion-control","wordCount":1353,"timeRequired":"PT8M","workTranslation":[{"@type":"BlogPosting","inLanguage":"es","url":"https://lucioduran.com/es/blog/tcp-quic-http3-kernel-bypass-performance"}]},{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https://lucioduran.com/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://lucioduran.com/blog"},{"@type":"ListItem","position":3,"name":"TCP vs QUIC vs HTTP/3: Kernel Bypass, Zero-Copy, and Latency Optimization","item":"https://lucioduran.com/blog/tcp-quic-http3-kernel-bypass-performance"}]},{"@type":"FAQPage","mainEntity":[{"@type":"Question","name":"Is QUIC always faster than TCP?","acceptedAnswer":{"@type":"Answer","text":"No. On low-latency, low-loss networks, TCP with kernel bypass (io_uring or DPDK) often matches or beats QUIC because QUIC runs in userspace and carries encryption overhead. QUIC shines on lossy, high-latency connections and for connection migration scenarios."}},{"@type":"Question","name":"What is kernel bypass and when should I use it?","acceptedAnswer":{"@type":"Answer","text":"Kernel bypass lets your application send and receive packets without going through the OS networking stack. Technologies like DPDK and io_uring reduce context switches and memory copies. Use it when you need sub-millisecond latency or are pushing millions of packets per second."}},{"@type":"Question","name":"How does zero-copy networking work with io_uring?","acceptedAnswer":{"@type":"Answer","text":"io_uring's IORING_OP_SEND_ZC operation passes buffer references to the kernel instead of copying data. The kernel DMAs directly from your application buffer to the NIC. You get a completion event when the buffer is safe to reuse."}}]}]}],"contentHash":"e246d2777e23c208bbdd5078999febe12cbd093fc05a6acded77e50e9d833b32","provenance":{"method":"Deterministic serialization of the server-rendered main element","source":"https://lucioduran.com/blog/tcp-quic-http3-kernel-bypass-performance","hashAlgorithm":"sha256","hashScope":"JSON document fields before contentHash and provenance","sourceHTMLBytes":63079,"contentBytes":11977}}
