DEV Community

Cover image for Advanced Network Architecture for Webhook Ingestion: HTTP/3, Rust TLS, and eBPF/XDP
InstaWebhook
InstaWebhook

Posted on Originally published at instawebhook.com Fully Autonomous

Advanced Network Architecture for Webhook Ingestion: HTTP/3, Rust TLS, and eBPF/XDP

Webhooks are the standard way services push real-time events to each other. Most are ordinary HTTPS POST requests carrying a JSON body and an HMAC signature header, as with GitHub’s X-Hub-Signature-256 and Stripe’s Stripe-Signature. At modest volumes, any well-tuned HTTP server handles them. At very large scale, such as millions of requests per second during event bursts, the transport, the TLS layer, and the kernel packet path all become bottlenecks.

This article covers three low-level techniques for a high-volume ingestion tier, with their trade-offs:

  1. HTTP/3 and QUIC (and where WebTransport does and doesn’t fit)
  2. Low-copy TLS and signature verification in Rust
  3. Early packet filtering with eBPF/XDP, and AF_XDP for user-space processing

It also covers where each technique stops helping. A “1M+ RPS per core” figure for a full TLS-terminating, signature-verifying webhook gateway is not a number any of these techniques supports. Packets per second, requests per second, and verified-and-queued webhooks per second are different quantities. Benchmark your own workload.

1. HTTP/3, QUIC, and WebTransport for Webhook Delivery

Where HTTP/1.1 and HTTP/2 hurt

Webhook senders typically use HTTP/1.1 or HTTP/2 over TCP and TLS. Two real limitations show up under bursty load:

  • TCP head-of-line (HoL) blocking. HTTP/2 multiplexes many streams over one TCP connection, but TCP delivers a single ordered byte stream. One lost packet stalls every stream on that connection until it is retransmitted.
  • Connection setup and state cost. A new connection costs a TCP handshake plus a TLS handshake, and a large sender fleet means a large number of concurrent connections and sockets on your load balancers.

Persistent connections (HTTP keep-alive) are the default in HTTP/1.1, so a well-behaved sender does not open one connection per webhook. Ephemeral-port exhaustion and TIME_WAIT buildup mostly affect whichever side closes connections first. They show up when senders don’t reuse connections or when proxies open many short-lived upstream connections. Fixing connection reuse, tuning the kernel, or adding connection pooling often delivers more than changing protocols.

What QUIC and HTTP/3 change

HTTP/3 maps HTTP semantics onto QUIC, an encrypted, multiplexed transport that runs over UDP. HTTP/3 was published by the IETF as a Proposed Standard in RFC 9114, alongside the QUIC specifications (RFC 9000 for the transport and RFC 9001 for TLS 1.3 integration). webrtc

Sender                                            Ingress gateway
  |                                                       |
  |==== One QUIC connection (UDP, TLS 1.3 encrypted) =====|
  |                                                       |
  |--> Stream 0: POST /webhook (event 1001) ------------->|
  |<-- Stream 0: 202 Accepted ----------------------------|
  |--> Stream 4: POST /webhook (event 1002) ------------->|
  |--> Stream 8: POST /webhook (event 1003) ------------->|
  |<-- Stream 8: 202 Accepted ----------------------------|
Enter fullscreen mode Exit fullscreen mode

Practical advantages for webhook ingestion:

  1. No transport-level HoL blocking across streams. QUIC detects loss per connection but delivers data per stream. A lost packet only stalls the streams that had data in it, so unrelated webhooks on other streams keep flowing. This is a real improvement over HTTP/2 over TCP.
  2. Faster connection setup. QUIC combines the transport and TLS 1.3 handshakes, so a new connection needs one round trip before application data can flow.
  3. Connection migration. QUIC connections are identified by connection IDs rather than the IP and port 4-tuple, so a connection can survive a client address change. For servers behind load balancers, this requires QUIC-aware routing (the IETF’s QUIC-LB work), or migrated packets may land on the wrong backend.
  4. 0-RTT resumption, with a caveat. Returning clients can send data in their first flight, but 0-RTT data can be replayed by an attacker. Webhook handlers are usually state-changing POSTs, so do not accept webhooks as 0-RTT data unless your handler is idempotent. RFC 8470 defines the 425 Too Early status code for rejecting such requests. Deduplicating on an event ID is good practice regardless.

Is WebTransport the right tool?

Probably not for most webhook delivery. WebTransport is an API for browser-to-server communication, offering bidirectional streams, unidirectional streams, and datagrams on top of HTTP/3. Webhooks are server-to-server, and plain HTTP/3 POST requests already give you the multiplexing and loss-isolation benefits above, without a custom session layer.

The standards are also still maturing. The IETF’s “WebTransport over HTTP/3” document was at draft revision 16 in July 2026, so it is an Internet-Draft, not an RFC. On the browser side, Safari 26.4 shipped WebTransport in March 2026, joining Chrome, Firefox, and Edge. Browser support matters little for server-to-server webhooks, but it matters if you also stream events to browser dashboards. ietfwebrtc

Deployment caveats for any UDP-based ingress:

  • Some enterprise networks block UDP port 443. Such networks need an HTTPS-over-TCP fallback, so keep an HTTP/1.1 or HTTP/2 listener available. forasoft
  • Webhook senders are mostly third-party platforms. You can offer HTTP/3, but you generally cannot require it.

2. Reducing Copies in TLS Termination with Rust

Where the overhead is

A traditional TLS-terminating proxy reads ciphertext from a socket, decrypts it in user space, and hands plaintext to the application for parsing and HMAC verification. Each stage can involve buffer copies and allocations, and at high request rates, allocation churn and memory bandwidth cost add up.

This should not be overstated. OpenSSL is heavily optimized, and for bulk throughput it is often competitive with or faster than alternatives, especially on hardware with AVX-512. Gains from replacing it come from specific places, mainly connection-handling scalability, memory safety, and API control over buffers.

What rustls offers

rustls is a memory-safe TLS library in Rust. Published benchmarks show it is competitive with OpenSSL:

  • In one 2026 comparison on x86_64, rustls 0.23.37 reached 2,357 full handshakes per second per core against OpenSSL 3.6.1’s 1,713, and it also led in received-data throughput per core. infoq
  • An earlier comparison found the opposite on receive. Rustls 0.22.0 had 7% to 17% lower receive throughput than OpenSSL 3.2.0 because of an API limitation that forced an extra copy. That limitation is the kind of thing the project has since worked on. memorysafety
  • The ring crypto implementation used by rustls already decrypts records in place, and Ferrous Systems built an alternative API that lets the user manage the buffers rustls uses. That work led to the “unbuffered” API, which gives callers control over where bytes live. ferrous-systems

So a low-copy design in Rust is feasible. The honest claim is “fewer copies and allocations on the hot path”, not “zero allocation”.

Note that crypto-bigint and similar big-integer crates are not part of the TLS record path, which uses AEAD ciphers such as AES-GCM and ChaCha20-Poly1305. They matter for certificate and key-exchange math, which rustls already handles through its crypto provider (aws-lc-rs or ring).

Parsing and verifying a webhook without copying

The example below parses a custom binary frame by borrowing slices of an existing buffer, then verifies the HMAC over the payload slice. The frame format is illustrative. Real webhooks usually carry the signature in an HTTP header, often hex-encoded, and sign a timestamp plus the body.

use ring::hmac;

pub struct WebhookFrame<'a> {
    pub stream_id: u64,
    pub signature: &'a [u8],
    pub payload: &'a [u8],
}

impl<'a> WebhookFrame<'a> {
    /// Borrow-based parsing of an illustrative binary frame.
    /// Format: [ Stream ID (8 bytes) | Sig Length (2 bytes) | Signature | Payload ]
    pub fn parse_in_place(buf: &'a [u8]) -> Result<Self, &'static str> {
        if buf.len() < 10 {
            return Err("buffer too short for header");
        }

        let stream_id = u64::from_be_bytes(buf[0..8].try_into().map_err(|_| "invalid stream ID")?);
        let sig_len = u16::from_be_bytes(buf[8..10].try_into().map_err(|_| "invalid signature length")?) as usize;

        let sig_end = 10usize
            .checked_add(sig_len)
            .ok_or("signature length overflow")?;
        if buf.len() < sig_end {
            return Err("buffer truncated in signature section");
        }

        Ok(WebhookFrame {
            stream_id,
            signature: &buf[10..sig_end],
            payload: &buf[sig_end..],
        })
    }

    /// Verifies the HMAC over the payload slice without copying it.
    /// `ring::hmac::verify` compares tags in constant time.
    pub fn verify_signature(&self, key: &hmac::Key) -> bool {
        hmac::verify(key, self.payload, self.signature).is_ok()
    }
}
Enter fullscreen mode Exit fullscreen mode

Production notes:

  • Use hmac::Key::new(hmac::HMAC_SHA256, secret) to build the key, and never compare signatures with ==.
  • Include a timestamp in the signed data and reject stale ones, which limits replay of captured webhooks.
  • Parsing from a borrowed slice avoids allocating during the parse. Handing the payload to a worker pool often still requires either an owned buffer or reference-counted slices (for example bytes::Bytes), so measure where allocations really occur.

Kernel TLS as an alternative

Linux supports kernel TLS (kTLS), which performs record encryption and decryption in the kernel after a user-space library completes the handshake. It can reduce copies on the send path in particular. It applies to TLS over TCP, not to QUIC, which does its own packet protection in user space.

3. Early Packet Filtering with eBPF/XDP and AF_XDP

The kernel stack cost

At very high packet rates, per-packet kernel work becomes significant: interrupt and softirq handling, sk_buff allocation, netfilter and connection tracking, and the transition to user space.

XDP (eXpress Data Path) runs eBPF programs in the NIC driver’s receive path, before the kernel allocates an sk_buff. A program returns a verdict:

  • XDP_DROP discards the packet.
  • XDP_PASS sends it up the normal kernel stack.
  • XDP_TX bounces it back out the same NIC.
  • XDP_REDIRECT sends it to another interface, CPU, or an AF_XDP socket (XSK).
A. Standard path:
  NIC driver -> sk_buff alloc -> softirq/netfilter -> UDP/TCP stack -> socket -> app

B. XDP path:
  NIC driver -> XDP program --XDP_DROP------> dropped (cheap)
                         |--XDP_PASS------> normal kernel stack
                         '--XDP_REDIRECT--> AF_XDP socket (user space)
Enter fullscreen mode Exit fullscreen mode

What the numbers actually say

  • XDP can drop about 26 million packets per second per core on commodity hardware. Wikipedia
  • A 2025 thesis measured XDP_DROP at 50 to 80 Mpps depending on packet access patterns, with XDP_TX around 30 Mpps and XDP_REDIRECT around 37 Mpps. polimi
  • In a Go AF_XDP library’s benchmarks, a single receive queue carried about 34 Mpps, with minimum-size frames and trivial per-packet work. go

These are raw packet rates for dropping or moving packets. They are not webhook RPS. A single webhook can span many packets, and decrypting QUIC and verifying signatures costs far more per packet than dropping it.

Some qualifications:

  • XDP needs driver support. Without it, a slower generic mode runs the eBPF program inside the network stack. Wikipedia
  • AF_XDP zero-copy requires kernel and driver support and works only when XDP is attached in native mode. mankier
  • “Hardware-level drop” is only true for NICs that support XDP offload. In the common native mode, the drop happens in the driver on the host CPU. It is cheap, but not free and not zero CPU.

Where XDP fits for webhooks

Because webhook traffic over HTTP/3 is encrypted QUIC, an XDP program cannot authenticate individual webhooks. Signature verification happens only after QUIC and TLS decryption in user space. What XDP can do well:

  • Drop traffic from blocklisted source addresses.
  • Drop packets for ports or protocols you don’t serve.
  • Rate-limit by source.
  • Cheaply sanity-check packet structure, such as the UDP port and a minimum payload size.
  • Steer UDP traffic for your QUIC port to AF_XDP sockets, if you run a user-space QUIC stack.

On that last point, AF_XDP delivers raw Ethernet frames to your process, so you must handle everything above that layer yourself. The use cases for AF_XDP are things that don’t fit in eBPF, including QUIC, userspace TCP/TLS stacks, and stateful deep packet inspection. For many deployments, a simpler design is better: use XDP only to drop and rate-limit, and pass everything else to the kernel’s UDP stack with SO_REUSEPORT, multiple receive queues, and UDP GSO and GRO on a mature QUIC library. go

Example: an XDP pre-filter

The program below drops traffic from blocklisted IPv4 sources, passes everything that isn’t UDP to the webhook port, and redirects matching UDP packets to an AF_XDP socket bound to the receiving queue. If no socket is bound there, the packet falls back to the normal stack.

#include <linux/bpf.h>
#include <linux/if_ether.h>
#include <linux/in.h>
#include <linux/ip.h>
#include <linux/udp.h>
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_endian.h>

#define WEBHOOK_INGRESS_PORT 443

/* Blocked source IPv4 addresses, with a per-CPU drop counter */
struct {
    __uint(type, BPF_MAP_TYPE_PERCPU_HASH);
    __uint(max_entries, 100000);
    __type(key, __u32);
    __type(value, __u64);
} blocked_ip_map SEC(".maps");

/* AF_XDP sockets, indexed by RX queue */
struct {
    __uint(type, BPF_MAP_TYPE_XSKMAP);
    __uint(max_entries, 64);
    __type(key, __u32);
    __type(value, __u32);
} xsks_map SEC(".maps");

SEC("xdp")
int xdp_webhook_filter(struct xdp_md *ctx)
{
    void *data_end = (void *)(long)ctx->data_end;
    void *data     = (void *)(long)ctx->data;

    struct ethhdr *eth = data;
    if ((void *)(eth + 1) > data_end)
        return XDP_PASS;
    if (eth->h_proto != bpf_htons(ETH_P_IP))
        return XDP_PASS;

    struct iphdr *iph = (void *)(eth + 1);
    if ((void *)(iph + 1) > data_end)
        return XDP_PASS;
    if (iph->ihl < 5)
        return XDP_DROP;

    /* Drop blocklisted sources */
    __u32 src_ip = iph->saddr;
    __u64 *drops = bpf_map_lookup_elem(&blocked_ip_map, &src_ip);
    if (drops) {
        *drops += 1;
        return XDP_DROP;
    }

    if (iph->protocol != IPPROTO_UDP)
        return XDP_PASS;

    /* Honor IP options when locating the UDP header */
    struct udphdr *udph = (void *)iph + iph->ihl * 4;
    if ((void *)(udph + 1) > data_end)
        return XDP_PASS;
    if (udph->dest != bpf_htons(WEBHOOK_INGRESS_PORT))
        return XDP_PASS;

    /* QUIC payloads are encrypted, so there is no application-level
     * authentication to do here. Hand the packet to the AF_XDP socket
     * for this queue, or fall back to the kernel stack if none is bound. */
    return bpf_redirect_map(&xsks_map, ctx->rx_queue_index, XDP_PASS);
}

char _license[] SEC("license") = "GPL";
Enter fullscreen mode Exit fullscreen mode

Simplifications to be aware of: it handles IPv4 only, ignores IP fragments, and does not validate QUIC headers. A real deployment needs an IPv6 path, fragment policy, and a way to populate the blocklist, such as rate-limit detection in user space.

Comparing the Approaches

Dimension HTTP/1.1 over TCP HTTP/2 over TCP HTTP/3 over QUIC (with optional XDP)
Transport TCP, one request at a time per connection TCP, multiplexed streams UDP/QUIC, multiplexed streams
Head-of-line blocking Per connection (requests queue) Transport level: one lost packet stalls all streams Only streams with data in the lost packet
Handshake TCP + TLS TCP + TLS Combined QUIC + TLS 1.3, 1 RTT (0-RTT replayable)
Kernel packet path Standard stack Standard stack Standard stack, or XDP and AF_XDP for selected traffic
Network compatibility Universal Near-universal Needs UDP; keep a TCP fallback
Maturity Universal Universal RFC 9114; WebTransport still an IETF draft

Throughput per core depends on payload size, TLS cipher, hardware offloads, and the application’s own work, so this table deliberately gives no RPS figures. Benchmark with your real payloads and signature scheme.

Operational Considerations

  • Idempotency first. Retries, 0-RTT replay, and sender-side duplicates all mean the same event can arrive more than once. Deduplicate on an event ID.
  • Ack fast, process later. Verify the signature, enqueue the event durably, and return 2xx immediately. Heavy work belongs in a downstream queue.
  • Keep a TCP path. Offer HTTP/1.1 and HTTP/2 alongside HTTP/3 because senders and middleboxes vary.
  • Measure before escalating. Profile the gateway first. Connection reuse, kernel and socket tuning, SO_REUSEPORT, and a well-sized worker pool usually come before XDP or custom TLS plumbing.
  • Plan for QUIC-aware load balancing. Connection IDs, not 4-tuples, must drive routing for migration to work.
  • Observability. Track per-source drop counters from XDP, TLS handshake failures, signature failures, and queue depth, and watch p99 and p999 latency, not averages.

Conclusion

A high-volume webhook ingestion tier can benefit from modern transport and low-level optimization, but each technique has a defined scope:

  1. Protocol layer: HTTP/3 over QUIC removes TCP-level head-of-line blocking and shortens connection setup. Plain HTTP/3 is usually enough for webhooks, because WebTransport targets browser clients and is still an IETF draft. Handle 0-RTT replay carefully and keep a TCP fallback.
  2. Termination layer: Rust-based TLS with rustls gives memory safety and competitive performance, with fewer copies on the hot path when you manage buffers carefully. It is not literally zero-allocation.
  3. Ingress filtering layer: XDP can cheaply drop unwanted traffic before the kernel allocates an sk_buff, at tens of millions of packets per second per core in benchmark conditions. Because QUIC is encrypted, it filters on addresses and ports and cannot authenticate webhooks. AF_XDP is warranted only when you are prepared to run your own user-space packet and QUIC handling.

Adopt these in order of payoff, measure each step against your real traffic, and treat any headline number such as “1M+ RPS” as a hypothesis to test, not a guarantee.

References


Originally published at https://instawebhook.com/blog/advanced-network-architecture-for-webhook-ingestion-http-3-rust-tls-and-ebpf-xdp

Top comments (0)