Zero-Allocation Webhook Parsers: Optimizing Go and Rust Ingress Buffers with SIMD
Learn how to eliminate GC pressure and memory overhead during multi-gigabit webhook bursts using SIMD-accelerated (AVX-512/NEON) zero-allocation JSON parsing in Go and Rust.

Zero-Allocation Webhook Parsers: Optimizing Go and Rust Ingress Buffers with SIMD
Webhook ingestion is a common bottleneck at the edge of event-driven systems. Payment processors, source-control platforms and IoT fleets can all send bursts of small JSON events to a single endpoint. Handlers built on general-purpose frameworks typically spend much of their CPU time on allocation and on building object trees for payloads that only need to be routed.
This article covers where that cost comes from, what SIMD-accelerated JSON parsing can and cannot do about it, and how to structure a Go or Rust ingress gateway around it. It also covers the limits of “zero allocation”, which is better treated as a target than as a guarantee.
1. Where Webhook Ingestion Spends Its Time
Most webhook endpoints are designed like ordinary REST endpoints. At a few hundred requests per second, that is fine. Under a burst, the per-request overhead adds up.
A typical handler does the following:
- Reads the body into a freshly allocated buffer. In Go, the standard
net/httpserver allocates aRequestand header map for every request, andio.ReadAllgrows a slice as it reads. - Copies bytes into strings. Converting
[]bytetostringin Go copies the data in the general case, although the compiler can avoid the copy in a few narrow cases such as map lookups and comparisons. Rust’sString::from_utf8orto_owneddoes the same. - Builds a full document tree. Unmarshaling into
map[string]interface{}orserde_json::Valueallocates for every object, array and string, even when the handler only needs one field. - Verifies the signature and routes. Webhook signatures cover the exact bytes the sender transmitted, so the HMAC must be computed over the raw body. Re-serialized JSON will not verify.
As a back-of-the-envelope figure, 50,000 requests per second at an average of 2 KB each is about 100 MB/s of payload. That is a modest amount of network traffic. The cost comes from the per-request allocations and tree building that sit on top of it.
What this means for Go and for Rust
Go’s collector is non-generational and concurrent (tri-color mark and sweep). It does not stop the world for long. It has two brief stop-the-world phases per cycle, which the runtime keeps in the sub-millisecond range. The main costs of a high allocation rate are:
- CPU spent on background marking, which uses roughly 25% of CPU during a cycle.
- Mark assists, where goroutines that allocate quickly are conscripted into GC work.
- More frequent cycles as the heap grows and shrinks.
For latency-sensitive services, even sub-millisecond pauses can be worth monitoring. A sustained p99 GC pause above roughly 10 ms is a sign that the collector is struggling with heap size.
Rust has no garbage collector, so the issue there is different. Every String, Vec and HashMap is a call into the allocator. Under high concurrency that means allocator contention, poor cache locality and per-request latency that depends on allocator state. The remedies are the same in spirit: borrow from the input buffer instead of copying, and reuse buffers instead of allocating new ones.
2. What SIMD Brings to JSON Parsing
SIMD (Single Instruction, Multiple Data) lets one instruction operate on a whole vector register. The common register widths are:
| Instruction set | Register width | Typical platform |
|---|---|---|
| ARM NEON | 128 bits (16 bytes) | aarch64 |
| AVX2 | 256 bits (32 bytes) | x86-64 since about 2013 |
| AVX-512 | 512 bits (64 bytes) | Some recent x86-64 server CPUs |
The reference design is simdjson, by Geoff Langdale and Daniel Lemire. Their paper, Parsing Gigabytes of JSON per Second, describes it as the first standard-compliant JSON parser to process gigabytes per second on a single core using commodity processors. It can use a quarter or fewer instructions than a state-of-the-art conventional parser such as RapidJSON.
The design has two stages:
- Stage 1 builds a structural index. It processes the input in 64-byte blocks. It uses vector comparisons to find quotes, backslashes and structural characters (
{ } [ ] : ,) and produces bitmasks. This step also works out which bytes are inside strings and validates UTF-8. - Stage 2 walks the index and builds a “tape”. This is a flat array describing the document structure, not a tree of heap objects.
A few of the claims commonly made about SIMD parsing need correcting:
- It does not process a block “in a single CPU cycle”. It uses a small number of vector instructions per block, with no data-dependent branches in stage 1.
- It is not tied to AVX-512. simdjson’s reference implementation targets several instruction sets. The Rust port
simd-jsonsupports AVX2, SSE4.2, NEON and WebAssembly SIMD128.simdjson-gorequires AVX2 and CLMUL, and uses AVX-512 only to speed up stage 1 where available. - SIMD speeds up parsing and validation. It does not remove the cost of reading from the socket, TLS, HTTP framing or signature verification.
For webhooks, the biggest win is often not raw parsing speed. A tape or on-demand parser lets you read one field, such as an event type, without constructing a full object model of a payload you are only going to forward.
3. Go: simdjson-go
github.com/minio/simdjson-go is a pure-Go port of simdjson, with no cgo. According to its README:
- It runs at roughly 40–60% of the speed of the C++ simdjson on average.
- It is about 10× faster than
encoding/json, in the project’s benchmarks. - It is a validating parser, so malformed input is rejected rather than silently accepted.
- It needs a CPU with both AVX2 and CLMUL. That means Intel Haswell (2013) or later, or AMD Ryzen/EPYC (2017) or later.
Two practical cautions apply before adopting it:
- Check maintenance status. The latest tagged release on pkg.go.dev, v0.4.5, was published in March 2023. At least one fork (DataDog’s) has been archived. Review the repository’s current activity before committing to it in a production gateway.
- The 10× figure is a benchmark result, not a promise. It depends on payload shape and on how much of the document you read. Measure with your own payloads.
Reusing parser state
simdjson.Parse accepts a previously used *ParsedJson to reuse, which avoids reallocating the tape and string buffers on each request. The sketch below shows the shape of a handler that reads a body into a pooled buffer, verifies the signature, and then extracts the event type. It is illustrative. Check the method names against the version of the package you pin.
package ingress
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
"sync"
"github.com/minio/simdjson-go"
)
const maxBody = 1 << 20 // 1 MiB; reject anything larger
type scratch struct {
body []byte
pj *simdjson.ParsedJson // reused between requests
}
var pool = sync.Pool{
New: func() any { return &scratch{body: make([]byte, 0, 64<<10)} },
}
func handle(secret []byte) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !simdjson.SupportedCPU() {
http.Error(w, "unsupported CPU", http.StatusInternalServerError)
return
}
s := pool.Get().(*scratch)
defer pool.Put(s)
// Read into the pooled buffer, bounded by maxBody.
buf := bytes.NewBuffer(s.body[:0])
if _, err := io.Copy(buf, io.LimitReader(r.Body, maxBody+1)); err != nil || buf.Len() > maxBody {
http.Error(w, "bad body", http.StatusRequestEntityTooLarge)
return
}
s.body = buf.Bytes()
// 1. Verify the signature over the raw bytes, before parsing.
if !validSignature(secret, s.body, r.Header.Get("X-Hub-Signature-256")) {
http.Error(w, "invalid signature", http.StatusUnauthorized)
return
}
// 2. Parse, reusing the previous ParsedJson where possible.
pj, err := simdjson.Parse(s.body, s.pj)
if err != nil {
http.Error(w, "invalid JSON", http.StatusBadRequest)
return
}
s.pj = pj
// 3. Walk the tape to read only the fields needed for routing,
// then enqueue the raw body and return 2xx quickly.
w.WriteHeader(http.StatusAccepted)
}
}
func validSignature(secret, body []byte, header string) bool {
const prefix = "sha256="
if len(header) <= len(prefix) {
return false
}
got, err := hex.DecodeString(header[len(prefix):])
if err != nil {
return false
}
mac := hmac.New(sha256.New, secret)
mac.Write(body)
return hmac.Equal(got, mac.Sum(nil))
}
Add "bytes" to the imports if you use this pattern. A few notes on what the sketch does and does not achieve:
- It reduces allocations on the body-reading and parsing paths. It is not allocation-free end to end.
net/httpstill allocates per request, andhmac.Newallocates unless you also pool the MAC. Servers that avoid the standard library’s per-request allocations, such asfasthttp, trade API convenience for lower overhead. - Verify with
go test -bench . -benchmemand look atallocs/op. Treat that number, not the design intent, as the measure of success. - If GC cost remains a concern,
GOGCandGOMEMLIMIT(available since Go 1.19) are separate levers.GOMEMLIMITsets a soft memory limit, and combining it with a tunedGOGCcan reduce collection frequency. It trades memory for CPU.
4. Rust: Borrowing from the Input Buffer
Two crates matter here, and they are easy to confuse:
simd-jsonis a Rust port of simdjson withserdecompatibility. It supports AVX2, SSE4.2, NEON and WASM SIMD128, with a native fallback. It offers borrowed and owned DOM value types and a lower-overhead tape API.simdjson-rustprovides bindings to the C++ simdjson library. As of this writing, the latest version listed on docs.rs is a0.4.0-alphapre-release.
For production use, simd-json is the more established choice.
An important constraint follows from how simd-json works: it parses in place and modifies the input buffer. Its documentation states that the input must be &mut [u8] because strings are unescaped in place. This affects design in two ways:
- Parsed strings borrow from your buffer, so the borrow checker ties their lifetime to it. That is where the zero-copy benefit comes from.
- You must verify the HMAC before parsing. After parsing, the buffer no longer holds the bytes the sender signed.
use hmac::{Hmac, Mac};
use sha2::Sha256;
use simd_json::prelude::*;
type HmacSha256 = Hmac<Sha256>;
pub struct Ingress {
secret: Vec<u8>,
buf: Vec<u8>, // reused across requests
}
impl Ingress {
pub fn new(secret: &[u8], capacity: usize) -> Self {
Self { secret: secret.to_vec(), buf: Vec::with_capacity(capacity) }
}
/// Verifies the signature, then reads the event type.
/// Returns an owned `String` for the type only to keep the example
/// simple; a hot path would route on a borrowed `&str` instead.
pub fn route(&mut self, body: &[u8], sig_hex: &str) -> Result<String, &'static str> {
// 1. Verify over the raw bytes, before the parser mutates anything.
let sig = hex::decode(sig_hex.trim_start_matches("sha256="))
.map_err(|_| "bad signature encoding")?;
let mut mac = HmacSha256::new_from_slice(&self.secret).map_err(|_| "bad key")?;
mac.update(body);
mac.verify_slice(&sig).map_err(|_| "signature mismatch")?; // constant-time
// 2. Copy into the reusable buffer; simd-json will modify it.
self.buf.clear();
self.buf.extend_from_slice(body);
// 3. Parse in place and read one field.
let value = simd_json::to_borrowed_value(&mut self.buf)
.map_err(|_| "invalid JSON")?;
value
.get("type")
.and_then(|t| t.as_str())
.map(str::to_owned)
.ok_or("missing event type")
}
}
Again, this is a sketch. Pin crate versions and check the API against them. Some caveats:
BorrowedValueis a DOM. It borrows string data from the buffer, but it still allocates for objects and arrays. For a routing-only workload, the tape API reduces that overhead further. Benchmark both against your payloads.- The copy into
self.bufis deliberate. If your network layer already gives you an owned, mutable buffer, you can verify and parse in place and skip it. - Build configuration matters. SIMD crates select instruction sets at compile time or runtime. Check how your target and build flags interact with the crate’s feature detection, and test on the actual production CPU architecture.
- Heap allocation is not eliminated. Allocating nothing at all in a Rust async server also requires choices in the HTTP stack. Count allocations with a profiler or a counting allocator rather than assuming.
5. Performance: What Is Actually Known
Be skeptical of any single headline multiplier for “SIMD vs standard”, including those in earlier drafts of this article. Throughput and latency depend on payload size, document structure, how much of the document you read, CPU generation and the rest of the stack.
What the primary sources support:
- The simdjson authors report that it processes gigabytes of JSON per second on a single core. An early README figure on one benchmark file (
twitter.json, Skylake) was about 2.2 GB/s, against about 0.10 GB/s for nlohmann/json. That is a different language and a different CPU generation from your service. simdjson-goreports roughly 10× the speed ofencoding/jsonand 40–60% of the C++ simdjson’s speed.- The simdjson paper reports using a quarter or fewer of the instructions of a state-of-the-art conventional parser.
Parsing is only one stage of the request. If your handler spends most of its time in TLS, the kernel or downstream I/O, a faster parser will not change overall throughput much. Profile first, and measure P99 and P99.9 latency under a realistic burst profile.
6. Gateway Architecture
A parser is only one component. The sender-facing requirements from the major webhook providers shape the rest of the design:
- GitHub expects a 2xx response within 10 seconds, or it terminates the connection and treats the delivery as failed. Its documentation recommends putting payloads on a queue and processing them asynchronously. It signs deliveries with HMAC-SHA256 in
X-Hub-Signature-256, recommends a constant-time comparison, and setsX-GitHub-EventandX-GitHub-Deliveryheaders for routing and replay protection. Missed deliveries should be redelivered once your server is back. - Stripe signs the raw request body with a timestamp in the
Stripe-Signatureheader (an HMAC-SHA256 over the timestamp, a dot, and the body). In live mode it retries failed deliveries with exponential backoff for up to three days. A retry is not a new event, so handlers should deduplicate on the event ID.
These behaviors suggest the following design:
- Read the raw body with a size limit. Reject oversized requests early.
- Verify the signature first. Use a constant-time comparison. This discards forged traffic before any parsing cost.
- Parse minimally. Extract only what routing needs, such as event type and ID.
- Persist, then acknowledge. Write the raw body to a durable queue or log, return 2xx, and do the real work in workers. Acknowledging before the event is durably stored risks losing it.
- Deduplicate. Use
X-GitHub-Deliveryor the Stripe event ID as an idempotency key. - Apply backpressure deliberately. A 429 or 5xx response only helps if the sender retries. Stripe retries non-2xx responses, but GitHub treats a failed delivery as failed until someone redelivers it. For senders without automatic retries, shedding load means losing events unless you reconcile later.
For network I/O, Go’s runtime uses the operating system’s readiness-based poller (epoll on Linux), so goroutine-per-connection code is already multiplexed. Rust services typically use an async runtime such as Tokio. io_uring-based runtimes exist in the Rust ecosystem, but whether they help depends on the workload and kernel version. Benchmark before adopting one.
Summary
SIMD-accelerated parsers such as simdjson-go and simd-json can cut parsing cost substantially, especially when you only need a few fields. Combined with pooled buffers and borrowed data, they reduce allocation pressure in both Go and Rust. A few points hold regardless of language:
- “Zero allocation” is a goal to verify with
-benchmem, allocation counters and profiles, not something a library name guarantees. - Verify HMAC signatures on the raw bytes before parsing, especially with
simd-json, which mutates its input. - Go’s GC is mostly concurrent, with short pauses. The cost of heavy allocation shows up as CPU overhead and mark assists as much as pause time.
- Check CPU requirements and library maintenance before building on a parser.
simdjson-gorequires AVX2 and CLMUL, and its last tagged release is from 2023. - Durability and idempotency matter more than parser speed for reliability: persist before acknowledging, and deduplicate on delivery IDs.
References
- Langdale, G. and Lemire, D., Parsing Gigabytes of JSON per Second (arXiv:1902.08318; VLDB Journal, 2019).
minio/simdjson-goREADME and pkg.go.dev listing (performance, AVX2/CLMUL requirement, v0.4.5).simd-lite/simd-jsonREADME (supported instruction sets, in-place parsing, borrowed values).simdjson-ruston docs.rs (bindings to C++ simdjson, pre-release status).- Go runtime documentation and GC guide (
go.dev/doc/gc-guide;GOGC,GOMEMLIMIT, concurrent mark and sweep). - GitHub Docs: Validating webhook deliveries and Best practices for using webhooks.
- Stripe documentation on webhook signatures and retry behavior.