InstaWebhook
November 2, 2026By InstaWebhook TeamWebhook Reliability

Memory-Mapped Webhook Buffers: Using `mmap` and Ring Buffers to Cut Kernel-User Crossings at 500k RPS

Discover how to scale webhook ingestion to 500k RPS by using mmap and lock-free ring buffers to bypass expensive kernel-user context switches.

Memory-Mapped Webhook Buffers: Using `mmap` and Ring Buffers to Cut Kernel-User Crossings at 500k RPS

Memory-Mapped Webhook Buffers: Using mmap and Ring Buffers to Cut Kernel-User Crossings at 500k RPS

Webhooks connect payment gateways, CI/CD pipelines and SaaS platforms. At scale, the service that receives them has to accept a request, verify it, acknowledge it quickly and hand it off for processing, hundreds of thousands of times per second.

This article looks at what actually costs CPU time at 500,000 requests per second. It shows where a shared-memory ring buffer built on mmap helps, how to write one correctly, and how modern io_uring features fit around it. It also covers where the approach does not help, because those limits matter as much as the gains.

1. Where the time goes at 500k RPS

500,000 RPS leaves a very small time budget per request. A single core handling all of that traffic would have 2 µs per request. Spread across 32 cores, each request gets about 64 µs of core time, and that has to cover TCP, TLS (if you terminate it), HTTP parsing, signature verification and the handoff to workers. A 1 KB payload at this rate is about 512 MB/s, or roughly 4 Gbit/s of body data before protocol overhead, so a 25 GbE NIC is not the limit. CPU time per request is.

The traditional receive path

A typical blocking or epoll-based server handles an incoming webhook like this:

  1. The NIC writes the packet into a kernel-owned receive ring by DMA.
  2. An interrupt (or a polled NAPI cycle) triggers softirq processing, and the kernel runs the packet through IP and TCP.
  3. The payload lands in the socket’s receive queue (sk_buff structures in kernel memory).
  4. The application calls recv/read, which switches into the kernel.
  5. The kernel copies the data into the user-space buffer (copy_to_user) and returns to user mode.
Code example
+-------------------------------------------------------------+
| USER SPACE                                                  |
|   Application buffer                                        |
|        ^                                                    |
|        | copy_to_user()  (copy + syscall entry/exit)        |
+--------|----------------------------------------------------+
| KERNEL SPACE                                                |
|   Socket receive queue (sk_buff)                            |
|        ^                                                    |
|        | TCP/IP stack processing                            |
+--------|----------------------------------------------------+
| HARDWARE                                                    |
|   NIC receive ring (DMA)                                    |
+-------------------------------------------------------------+

A system call is not a context switch

The terminology matters here. A system call is a privilege-mode transition within the same thread. A context switch is the scheduler replacing one thread or process with another, which usually means saving and restoring state and possibly changing address spaces.

Both cost something, but they are different amounts. In one widely cited 2010 measurement on older Intel hardware, the cheapest system call (gettid) cost roughly 52 to 105 ns depending on the CPU. The same benchmark measured process context switches at about 1.3 to 1.9 µs, and about 3 µs when futex calls were included. A University of Rochester study put the direct cost of a context switch at about 3.4 µs on 2000s-era hardware. It also found that the indirect cost of refilling caches ranged from several microseconds to hundreds of microseconds, depending on working-set size. More recent measurements report a direct kernel-level context switch of around 0.62 µs. These numbers vary with CPU, kernel, mitigations and measurement method, so treat them as orders of magnitude rather than constants. Always measure your own hardware.

The practical conclusion is that per-request system calls, copies, and scheduler wake-ups add up. At 500k RPS they can take a meaningful share of your CPU budget. The size of that share depends on your workload, so profile with perf before assuming a number.

2. What mmap and ring buffers actually buy you

mmap() maps a file or shared-memory object into a process’s address space. If two processes map the same object with MAP_SHARED, they can exchange data through plain loads and stores. The kernel is only involved when the mapping is created and when pages fault in, not on every message.

That makes it a good fit for handing parsed webhooks from an ingestion process to worker processes. It does not remove the kernel from the network receive path, because TCP still runs in the kernel unless you bypass the stack entirely (see section 4).

Code example
+-----------------------------------------------------------------------+
| SHARED MEMORY REGION (mmap of a shm object)                           |
|                                                                       |
|   +------------+------------+------------+------------+               |
|   | Slot 0     | Slot 1     | Slot 2     | Slot 3     | ...           |
|   | (Payload)  | (Payload)  | (Payload)  | (Empty)    |               |
|   +------------+------------+------------+------------+               |
|         ^                                     ^                       |
|    [Head: consumer reads]             [Tail: producer writes]         |
+-----------------------------------------------------------------------+

A ring buffer is a good structure for this handoff because:

  • No allocation on the hot path. Slots are preallocated, so enqueue and dequeue are a few arithmetic and atomic operations in constant time.
  • Cache-friendly layout. Slots are contiguous and sequentially accessed, which suits hardware prefetchers. Aligning the head and tail counters to separate cache lines also avoids false sharing.
  • No locks for the single-producer, single-consumer (SPSC) case. One side only writes the tail, the other only writes the head, so acquire/release atomics are enough. Multiple producers or consumers on one ring need additional machinery (section 3).

If ingestion and workers are threads in one process, an ordinary in-process ring buffer gives you the same benefits without mmap. Use shared memory when you need process isolation (a worker crash must not take down the listener), independent deploys, or the buffer to survive an ingestion restart.

3. A correct SPSC shared-memory ring

Here is a compact C++ implementation for one producer process and one consumer process. It differs from naive versions in a few ways that matter:

  • It uses a power-of-two capacity, so the index is a bitmask rather than a modulo.
  • It uses monotonically increasing 64-bit counters, so full and empty are unambiguous and every slot is usable.
  • It stores each payload’s length in its slot.
  • It separates “create” from “attach”, so two processes never race to initialize the header.
Code example
#include <atomic>
#include <cstdint>
#include <cstring>
#include <stdexcept>
#include <fcntl.h>
#include <sys/mman.h>
#include <unistd.h>

constexpr size_t kCacheLine = 64;

struct RingHeader {
    alignas(kCacheLine) std::atomic<uint64_t> head;  // written by consumer
    alignas(kCacheLine) std::atomic<uint64_t> tail;  // written by producer
    alignas(kCacheLine) uint32_t capacity;           // power of two
    uint32_t slot_size;                              // includes 4-byte length prefix
};

static_assert(std::atomic<uint64_t>::is_always_lock_free,
              "atomics in shared memory must be lock-free");

class ShmRing {
public:
    // Producer side creates; consumer side attaches.
    static ShmRing create(const char* name, uint32_t capacity, uint32_t slot_size) {
        if (capacity == 0 || (capacity & (capacity - 1)) != 0)
            throw std::invalid_argument("capacity must be a power of two");
        size_t bytes = sizeof(RingHeader) + size_t(capacity) * slot_size;
        int fd = shm_open(name, O_CREAT | O_EXCL | O_RDWR, 0600);
        if (fd < 0) throw std::runtime_error("shm_open failed");
        if (ftruncate(fd, bytes) != 0) { close(fd); throw std::runtime_error("ftruncate failed"); }
        ShmRing r(fd, bytes);
        r.h_->capacity = capacity;
        r.h_->slot_size = slot_size;
        r.h_->head.store(0, std::memory_order_relaxed);
        r.h_->tail.store(0, std::memory_order_release);
        return r;
    }

    static ShmRing attach(const char* name) {
        int fd = shm_open(name, O_RDWR, 0600);
        if (fd < 0) throw std::runtime_error("shm_open failed");
        // Map the header first to learn the full size, then remap.
        void* hdr = mmap(nullptr, sizeof(RingHeader), PROT_READ, MAP_SHARED, fd, 0);
        if (hdr == MAP_FAILED) { close(fd); throw std::runtime_error("mmap failed"); }
        auto* ph = static_cast<RingHeader*>(hdr);
        size_t bytes = sizeof(RingHeader) + size_t(ph->capacity) * ph->slot_size;
        munmap(hdr, sizeof(RingHeader));
        return ShmRing(fd, bytes);
    }

    bool push(const void* data, uint32_t len) {            // producer only
        if (len > h_->slot_size - sizeof(uint32_t)) return false;
        uint64_t tail = h_->tail.load(std::memory_order_relaxed);
        uint64_t head = h_->head.load(std::memory_order_acquire);
        if (tail - head == h_->capacity) return false;     // full: apply backpressure
        char* slot = slots_ + (tail & (h_->capacity - 1)) * h_->slot_size;
        std::memcpy(slot, &len, sizeof(len));
        std::memcpy(slot + sizeof(len), data, len);
        h_->tail.store(tail + 1, std::memory_order_release);
        return true;
    }

    bool pop(void* dst, uint32_t dst_cap, uint32_t& out_len) {  // consumer only
        uint64_t head = h_->head.load(std::memory_order_relaxed);
        uint64_t tail = h_->tail.load(std::memory_order_acquire);
        if (head == tail) return false;                    // empty
        const char* slot = slots_ + (head & (h_->capacity - 1)) * h_->slot_size;
        uint32_t len;
        std::memcpy(&len, slot, sizeof(len));
        if (len > dst_cap) return false;
        std::memcpy(dst, slot + sizeof(len), len);
        out_len = len;
        h_->head.store(head + 1, std::memory_order_release);
        return true;
    }

private:
    ShmRing(int fd, size_t bytes) : bytes_(bytes) {
        void* m = mmap(nullptr, bytes, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
        close(fd);  // the mapping stays valid after the descriptor is closed
        if (m == MAP_FAILED) throw std::runtime_error("mmap failed");
        h_ = static_cast<RingHeader*>(m);
        slots_ = static_cast<char*>(m) + sizeof(RingHeader);
    }
    RingHeader* h_;
    char* slots_;
    size_t bytes_;
};

A few things to note about this design:

  • It is SPSC. The producer only reads head and writes tail, and the consumer does the reverse. The release store on one side pairs with the acquire load on the other, so a consumer that sees the new tail also sees the slot contents. A fetch_add on the tail does not make this multi-producer safe, because a producer could claim a slot before finishing its write. To get multiple producers, use one ring per producer (simple and fast), or use a per-slot sequence-number scheme such as Dmitry Vyukov’s bounded MPMC queue.
  • Use one ring per worker, or per ingestion thread, as the scaling strategy. The ingestion layer can shard by connection or hash, and each worker drains its own ring. This keeps every ring SPSC and avoids cross-core contention.
  • Slot size is a trade-off. Fixed slots make indexing trivial but waste space for small payloads. If your webhooks vary widely in size, consider variable-length records with a length header and wrap-around handling, or a second tier for oversized payloads.
  • The ring only moves bytes. Authentication, parsing and idempotency live in the code on either side of it.

4. Pairing the ring with io_uring for network ingress

io_uring is the Linux async I/O interface merged in kernel 5.1. It uses a submission queue and a completion queue shared between user space and the kernel, so many operations can be queued and reaped with few or no system calls.

Several features matter for a webhook receiver, and each needs a minimum kernel version:

  • Batching. One io_uring_enter() call can submit many queued operations and wait for completions. In ordinary mode you still enter the kernel, but once per batch rather than once per operation.
  • SQPOLL. With IORING_SETUP_SQPOLL, a kernel thread polls the submission queue, so the application can submit and reap I/O without issuing system calls in the steady state. The cost is that the polling thread consumes a CPU core. Unprivileged use of SQPOLL has been allowed since kernel 5.12.
  • Multishot receive and provided buffer rings. Multishot recv (kernel 6.0 and later) lets a single submission produce a completion for every arrival, with the kernel picking buffers from a pre-registered pool. This removes the need to re-arm a receive after every packet. Receive bundles, which fill several buffers per operation, arrived in kernel 6.10.
  • Zero-copy receive (zcrx). Kernel 6.15 added io_uring zero-copy receive, which lets the NIC deliver payload data directly into application memory. This is the feature that actually removes copy_to_user from the receive path. It has strict requirements: the NIC must support hardware header/data split, you must configure flow steering and RSS so that only the intended flows hit a zero-copy receive queue, and the ring must be created with IORING_SETUP_SINGLE_ISSUER, IORING_SETUP_DEFER_TASKRUN and IORING_SETUP_CQE32. The NIC configuration is done outside io_uring. In a demonstration described by the pull request coverage, a 200G link was saturated from a single CPU core.

Without zcrx, a normal io_uring socket receive still copies data from kernel buffers into your buffers. What you save is system-call and wake-up overhead, not the copy itself. An accurate description of the pipeline is:

Code example
+-------------------------------------------------------------------+
| NETWORK: kernel TCP/IP stack, io_uring multishot recv             |
|          (zcrx on supported NICs and kernels >= 6.15)             |
+-------------------------------------------------------------------+
                                  │
                                  ▼
+-------------------------------------------------------------------+
| INGESTION THREADS: HTTP parsing, auth, size limits                |
+-------------------------------------------------------------------+
                                  │
                                  ▼  (memcpy into shared slot, no syscall)
+-------------------------------------------------------------------+
| SHARED MEMORY: one SPSC ring per worker                           |
+-------------------------------------------------------------------+
        │                         │                         │
        ▼                         ▼                         ▼
+---------------+         +---------------+         +---------------+
| Worker 1      |         | Worker 2      |         | Worker 3      |
+---------------+         +---------------+         +---------------+

Workers can verify HMAC-SHA256 signatures, apply business logic and dispatch downstream. The ingestion layer should acknowledge the sender only after the payload is safely enqueued (and, if you need durability, persisted), so a slow downstream never stretches your response time.

Full kernel bypass is a different tool. DPDK and AF_XDP move packet handling into user space. DPDK uses poll-mode drivers that poll NIC queues from user space, and its AF_XDP driver redirects packets from a NIC queue into a user-space memory buffer. Both bypass the kernel network stack, which means you must supply your own TCP, and TLS if needed. That is a large step up in complexity, and for a typical HTTP webhook receiver it is usually unnecessary. io_uring keeps the kernel stack and its tooling (ss, tcpdump, firewalling, congestion control).

5. Production hardening

Backpressure and durability

When consumers fall behind, the ring fills, and push returns false. Decide in advance what happens:

  • Shed load. For low-value telemetry, answer with 429 Too Many Requests (or 503 with a Retry-After) and let the sender retry. Dropping the oldest entries is a deliberate data-loss policy, so only use it where loss is acceptable.
  • Persist before acknowledging. For payments or other critical events, a shared-memory ring is not durable. A shm_open object lives in memory (on Linux typically on tmpfs), so a machine reboot loses it. Write to an append-only log or a durable queue before returning 2xx, and make processing idempotent, because senders retry and at-least-once delivery means duplicates.

Be careful with file-backed mmap

A MAP_SHARED mapping of a regular file lets the kernel write dirty pages back to storage on its own schedule, and it can evict pages under memory pressure. The 2022 CIDR paper “Are You Sure You Want to Use MMAP in Your Database Management System?” lists the resulting hazards. Page faults can happen at any memory access and stall the thread. Evicting pages requires TLB shootdowns, which are inter-processor interrupts that can hurt multi-core scaling. I/O errors surface as SIGBUS signals rather than return codes. The paper is about database buffer pools, but the same mechanisms apply to a file-backed ingest buffer. For a hot ring, prefer an anonymous or shm_open-backed region, pre-fault it at startup (for example with MAP_POPULATE or by touching every page), and use mlock or huge pages if you need to keep it resident. If you need a durable log, write it with explicit I/O so you control when data is flushed and how errors are handled.

NUMA and CPU affinity

On multi-socket machines, memory access cost depends on which socket owns the memory. Keep the NIC’s interrupt or polling cores, the ingestion threads and the shared-memory region on the same NUMA node where possible, and pin threads to cores with pthread_setaffinity_np or taskset. This reduces scheduler migrations and cross-socket cache traffic. Verify the effect with perf rather than assuming it.

Check the rest of the path

Shared memory only fixes the handoff. At this rate you should also look at TLS (many deployments terminate it at a load balancer so the webhook service sees plain HTTP), connection reuse, HTTP parsing cost and signature verification, which is a real CPU cost at 500k verifications per second.

6. What measurements actually show

You will see articles quote tidy tables claiming a 5x throughput gain and a 10x p99 improvement for an mmap/io_uring design. Treat any such table with suspicion unless the benchmark setup is published, because results depend on hardware, kernel version, payload size, connection count and what the application does per request.

Published, independent measurements are more modest:

  • A University of Edinburgh MSc project comparing io_uring with epoll in HTTP server benchmarks found io_uring ahead by up to 33% in throughput.
  • A single-threaded TCP echo benchmark with 1024-byte payloads measured about 176k requests/s for io_uring against 169k for epoll with one connection, and nearly identical results (about 421k) at 256 connections. At 512 connections epoll was ahead.
  • A Netty benchmark on Linux 6.6 found io_uring with half as many worker threads delivered about 3% higher throughput than epoll, with over twice the per-thread efficiency and far fewer CPU migrations.
  • The maintainers of uWebSockets have reported that io_uring can lose to epoll for larger messages when it uses more memory and falls out of CPU cache, and that localhost benchmarks can mislead.

The gains are real, but they are usually tens of percent, not an order of magnitude, and they depend heavily on the workload. For reference, public HTTP framework benchmarks (such as HttpArena) show well-tuned epoll and io_uring servers exceeding a million simple requests per second on large multi-core machines. So 500k RPS is reachable without exotic techniques when the per-request work is small. The real constraint is how much work each webhook needs.

To evaluate this design for your own workload:

  1. Start with a baseline: a well-tuned epoll server with in-process queues, under realistic payloads and connection counts.
  2. Profile with perf and flame graphs to see how much time is actually spent in system calls, copies, scheduling and TLS.
  3. Change one thing at a time (batching or multishot receive, then SQPOLL, then shared-memory handoff) and measure throughput, CPU per request and p99/p999 latency.
  4. Test on separate client and server machines over a real network, because loopback results can mislead.

Summary

Reaching 500k RPS on a webhook ingestion service means spending a few microseconds of CPU per request, so syscalls, copies, wake-ups and lock contention all matter. A shared-memory SPSC ring buffer gives you a clean, allocation-free, syscall-free handoff between ingestion and worker processes. io_uring reduces the cost of the network side through batching, multishot receive, optional SQPOLL, and zero-copy receive on kernel 6.15+ with supporting NICs. Neither technique removes the kernel TCP stack, and independent benchmarks suggest incremental rather than dramatic gains over a well-tuned epoll server. The reliable path is to measure your baseline, adopt these techniques where profiling shows a bottleneck, and design for backpressure and durability from the start.

References