Webhook receivers do a lot of unglamorous work. They authenticate each request, parse JSON, decide whether the event matters, and only then hand it to business logic. Done on a central application server, every rejected or irrelevant event still costs a full deserialization pass. Done at the edge, the same work can discard bad traffic before it travels anywhere.
This article walks through one way to build that edge intake layer: a Rust crate compiled to WebAssembly (WASM), running inside a Cloudflare Worker. It verifies a provider’s HMAC signature and parses the payload with borrowed, zero-copy string slices. It also covers where “zero-copy” stops being literally true, which matters more than most write-ups admit.
1. Why Move Webhook Intake to the Edge
Webhooks are how many services push events to each other: Stripe payment events, GitHub repository events, Shopify store updates. A receiver typically needs to:
- Verify that the request really came from the provider (an HMAC signature over the raw body).
- Reject stale or replayed requests.
- Parse enough of the JSON to route or filter the event.
- Acknowledge quickly, because providers retry on slow or failed responses.
Steps 1 to 3 are cheap per request, but they scale with volume, and they run for every request, including the ones you were going to drop anyway. Running them at the edge means invalid or uninteresting requests never reach your origin.
Cloudflare Workers is a good fit for this. Workers run on V8 isolates rather than containers or virtual machines. Cloudflare has said an isolate-based Worker loads in about 5 milliseconds, and since 2020 it has pre-warmed Workers during the TLS handshake so that the load often finishes before the client’s request arrives. As Worker bundles grew, cold starts began to outlast that handshake window, so Cloudflare later introduced Worker sharding, which routes requests to already-running instances. Cloudflare reports that about 99.99% of requests now hit a warm Worker.
Cost also depends on CPU time here. Under Cloudflare’s Standard pricing model, the Workers Paid plan includes 10 million requests and 30 million CPU milliseconds per month. Beyond that, it charges $0.30 per additional million requests and $0.02 per additional million CPU milliseconds. The Free plan allows 100,000 requests per day and 10 ms of CPU time per invocation. Time spent waiting on network I/O doesn’t count toward CPU time, so the parsing and hashing you do inside the Worker is the part you pay for.
TRADITIONAL
Payload -> Edge proxy (forward only) -> Gateway -> Origin: verify + parse + route
EDGE INTAKE
Payload -> Worker: verify signature + borrow-parse + route/reject
|
+-> only accepted events are forwarded to the origin
2. Where the Copies Actually Happen
The common pitch for edge WASM parsing is that data never gets copied. That is too strong, and it helps to be precise about where bytes move.
Inside JavaScript. JSON.parse() turns a payload into a graph of heap objects: one for every object, array, and string. These are managed by V8’s garbage collector. The cost depends on payload shape, and you should measure it for your own traffic rather than assume a figure. A worker that only needs to authenticate and inspect two fields has no need for that graph.
At the JS/WASM boundary. WebAssembly code works inside its own linear memory. When JavaScript passes a Uint8Array to a wasm-bindgen-generated function that takes &[u8], the glue code copies the bytes into WASM linear memory first. That is a single memcpy of the raw body. It is far cheaper than building an object graph, but it is not zero.
If you want to avoid even that copy, wasm-bindgen allows an approach where Rust allocates a buffer and exposes a pointer and length so JavaScript can write directly into linear memory. This is more complex and needs care: memory views become invalid if WASM memory grows. For webhook-sized payloads, the extra copy is usually not worth the complexity.
Inside Rust. This is where zero-copy parsing applies. Once the bytes are in linear memory, the parser can hand back string fields as slices pointing into that buffer, with no per-field heap allocation.
The accurate claim, then, is this: zero-copy parsing eliminates the per-field allocations and the object graph, while a single boundary copy of the body remains.
3. Zero-Copy Deserialization in Rust
In Rust, serde can deserialize into owned or borrowed types. An owned structure allocates a String for each text field:
#[derive(serde::Deserialize)]
struct EventOwned {
id: String,
#[serde(rename = "type")]
event_type: String,
}
A borrowed structure holds slices into the input instead:
#[derive(serde::Deserialize)]
struct EventBorrowed<'a> {
id: &'a str,
#[serde(rename = "type")]
event_type: &'a str,
}
A &str is a pointer plus a length. On wasm32 that is two 32-bit values, or 8 bytes. The borrowed struct cannot outlive the buffer it was parsed from, which Rust’s lifetime checker enforces at compile time.
The escape-sequence catch
JSON strings can contain escape sequences such as \" or \n. A &str pointing at the raw input cannot represent the unescaped value, because the unescaped text doesn’t exist anywhere in the buffer. With serde_json, deserializing into a plain &str therefore fails if the string contains an escape.
The fix is Cow<'a, str> with #[serde(borrow)]. serde borrows the slice when the string has no escapes and allocates an owned String only when it must:
use std::borrow::Cow;
#[derive(serde::Deserialize)]
struct EventSafe<'a> {
#[serde(borrow)]
id: Cow<'a, str>,
#[serde(rename = "type", borrow)]
event_type: Cow<'a, str>,
}
Identifiers and event-type names rarely contain escapes, so in practice most fields borrow. This is “zero-copy in the common case, correct in every case.”
SIMD
WebAssembly SIMD is supported on Workers. You can enable it for Rust with RUSTFLAGS="-C target-feature=+simd128" when building for wasm32-unknown-unknown. The simd-json crate, a Rust port of the simdjson parser, uses simd128 on WASM when it is available.
Two caveats are worth knowing before reaching for it:
-
simd-jsontakes a mutable buffer (&mut [u8]) because it de-escapes strings in place. That is why it avoids allocating new memory for string contents, and also why it is not strictly zero-copy for escaped strings. It also means you must verify the HMAC before parsing, since parsing rewrites the bytes the signature was computed over. - Whether SIMD helps depends on payload size and shape. For small webhook bodies, the fixed costs of the boundary copy and HMAC computation can dominate. Benchmark before adopting it.
4. Implementation
The example below verifies a Stripe-style webhook. It uses the pure-Rust RustCrypto crates hmac and sha2, which avoid needing a C toolchain when targeting wasm32-unknown-unknown.
How Stripe signs webhooks
Stripe sends a Stripe-Signature header shaped like t=<unix_timestamp>,v1=<hex_hmac>, and it may include more than one v1 entry while a signing secret is being rotated. The signed payload is the timestamp, a literal ., and the raw request body, hashed with HMAC-SHA256 using your endpoint secret. Stripe’s libraries default to a 300-second timestamp tolerance to defend against replays. The raw body must be used exactly as received, so you must verify before any framework re-serializes it.
Other providers differ. GitHub sends X-Hub-Signature-256 as sha256=<hex> over the raw body, and Shopify sends X-Shopify-Hmac-Sha256 as a base64 digest. Check each provider’s documentation rather than reusing one scheme everywhere.
Cargo.toml
[package]
name = "edge-webhook-parser"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[dependencies]
wasm-bindgen = "0.2"
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1", features = ["raw_value"] }
hmac = "0.12"
sha2 = "0.10"
hex = "0.4"
[profile.release]
opt-level = "z" # optimize for size
lto = true
codegen-units = 1
panic = "abort"
The raw_value feature is required for serde_json::value::RawValue.
src/lib.rs
use std::borrow::Cow;
use hmac::{Hmac, Mac};
use serde::Deserialize;
use serde_json::value::RawValue;
use sha2::Sha256;
use wasm_bindgen::prelude::*;
type HmacSha256 = Hmac<Sha256>;
/// Borrows from the input buffer wherever the JSON has no escape sequences.
#[derive(Deserialize)]
struct StripeEvent<'a> {
#[serde(borrow)]
id: Cow<'a, str>,
#[serde(rename = "type", borrow)]
event_type: Cow<'a, str>,
created: u64,
#[serde(borrow)]
data: EventData<'a>,
}
#[derive(Deserialize)]
struct EventData<'a> {
// The nested object is kept as an unparsed slice of the original bytes.
#[serde(borrow)]
object: &'a RawValue,
}
const OK: u8 = 0;
const BAD_SIGNATURE: u8 = 1;
const BAD_PAYLOAD: u8 = 2;
const IGNORED: u8 = 3;
#[wasm_bindgen]
pub struct WebhookProcessor {
secret: Vec<u8>,
tolerance_secs: u64,
}
#[wasm_bindgen]
impl WebhookProcessor {
#[wasm_bindgen(constructor)]
pub fn new(secret: &str, tolerance_secs: u32) -> WebhookProcessor {
WebhookProcessor {
secret: secret.as_bytes().to_vec(),
tolerance_secs: tolerance_secs as u64,
}
}
/// Returns 0 = accepted, 1 = bad signature, 2 = bad payload, 3 = ignored event type.
pub fn process(&self, body: &[u8], signature_header: &str, now_secs: f64) -> u8 {
if !self.verify(body, signature_header, now_secs as u64) {
return BAD_SIGNATURE;
}
let event: StripeEvent = match serde_json::from_slice(body) {
Ok(e) => e,
Err(_) => return BAD_PAYLOAD,
};
// Routing decisions can use the borrowed fields directly.
let _ = (&event.id, event.created, event.data.object.get().len());
match event.event_type.as_ref() {
"payment_intent.succeeded" | "charge.refunded" => OK,
_ => IGNORED,
}
}
fn verify(&self, body: &[u8], header: &str, now: u64) -> bool {
let ts_str = match header.split(',').find_map(|p| p.strip_prefix("t=")) {
Some(t) => t,
None => return false,
};
let ts: u64 = match ts_str.parse() {
Ok(t) => t,
Err(_) => return false,
};
if now.abs_diff(ts) > self.tolerance_secs {
return false; // stale or replayed
}
header
.split(',')
.filter_map(|p| p.strip_prefix("v1="))
.filter_map(|h| hex::decode(h).ok())
.any(|sig| {
let mut mac = HmacSha256::new_from_slice(&self.secret)
.expect("HMAC accepts keys of any length");
mac.update(ts_str.as_bytes());
mac.update(b".");
mac.update(body);
mac.verify_slice(&sig).is_ok() // constant-time comparison
})
}
}
A few design choices to note:
- Signature verification runs before JSON parsing. Unauthenticated bytes never reach the parser, and the check is performed on the exact bytes received.
-
verify_sliceperforms a constant-time comparison, which avoids leaking information through timing. - The
data.objectfield is kept as aRawValue, a borrowed, validated-but-unparsed slice. If the Worker only needs to route and forward, it never builds the nested structure. - Return codes are plain integers, so nothing complex crosses the boundary on the way out.
worker.js
Build the crate with wasm-pack build --target web (or your preferred wasm-bindgen workflow) and import the compiled module. One common pattern looks like this:
import { initSync, WebhookProcessor } from "./pkg/edge_webhook_parser.js";
import wasmModule from "./pkg/edge_webhook_parser_bg.wasm";
let processor;
export default {
async fetch(request, env) {
if (request.method !== "POST") {
return new Response("Method Not Allowed", { status: 405 });
}
if (!processor) {
initSync({ module: wasmModule });
processor = new WebhookProcessor(env.WEBHOOK_SECRET, 300);
}
const signature = request.headers.get("Stripe-Signature") ?? "";
const body = new Uint8Array(await request.arrayBuffer());
const code = processor.process(body, signature, Date.now() / 1000);
switch (code) {
case 0:
// Forward the verified event downstream (queue, origin, Durable Object, ...)
return new Response("accepted", { status: 200 });
case 3:
// Verified but uninteresting: acknowledge so the provider stops retrying.
return new Response("ignored", { status: 200 });
case 1:
return new Response("invalid signature", { status: 400 });
default:
return new Response("malformed payload", { status: 400 });
}
},
};
Cloudflare’s Rust on Workers documentation also describes the workers-rs crate, which lets you write the whole Worker in Rust and handles the wasm-bindgen glue for you. It is often the more maintainable route if most of your Worker logic is in Rust anyway. Note that the Worker runtime is single-threaded, so threads and threaded async runtimes such as Tokio are not available.
5. Measuring Instead of Guessing
You will find tables online comparing JavaScript and Rust/WASM parsing with precise latency and throughput figures. Treat any such numbers with suspicion unless they come with the payload shape, the hardware, the methodology, and the code. The result depends heavily on payload size, how many fields you actually read, whether you hit the escape-handling fallback, and how much of the request time is spent on the network rather than in your code.
A sound way to evaluate this for your own traffic:
-
Build three variants of the same Worker: pure JavaScript with
JSON.parse, Rust/WASM with ownedserdetypes, and Rust/WASM with borrowed types. - Use realistic payloads: real or representative webhook bodies from your providers, in realistic size mixes, including large and escape-heavy ones.
-
Measure CPU time, not wall-clock time. CPU time is what Cloudflare bills and what the free plan’s 10 ms cap limits. Use Workers analytics,
wrangler tail, and local CPU profiling with DevTools, which Cloudflare’s limits documentation recommends for finding CPU-heavy sections. - Compare tail latency and cost per million requests, not just averages.
The likely finding is that signature verification, not parsing, dominates for small payloads, and that the gains from borrowed parsing grow with payload size and with how selectively you read fields.
6. Large Payloads: Partial Parsing and Streaming
Partial deserialization with RawValue
If the Worker needs only a few top-level fields for routing, the RawValue pattern keeps the rest untouched:
use serde::Deserialize;
use serde_json::value::RawValue;
#[derive(Deserialize)]
struct RoutingView<'a> {
#[serde(borrow)]
event_id: Cow<'a, str>,
#[serde(borrow)]
event_type: Cow<'a, str>,
#[serde(borrow)]
payload: &'a RawValue,
}
serde_json still scans the nested value to find where it ends and to confirm it is valid JSON, but it does not build a typed structure from it, and no allocations are made for its contents.
Streaming the signature check
Workers can process request bodies incrementally with the Web Streams API. HMAC is an incremental construction, so you can feed it chunks as they arrive instead of buffering the whole body for hashing. That keeps memory flat for the signature step even on large payloads.
There is an important limit, though: you cannot know a signature is invalid until you have hashed the entire body, because the comparison happens against the final MAC. Streaming does not allow aborting a 10 MB upload “halfway through” on a signature failure. What you can reject early, before reading the body, is:
- Requests with a missing or malformed signature header.
- Requests whose signed timestamp is outside the tolerance window (for schemes like Stripe’s, where the timestamp is in the header).
- Requests whose
Content-Lengthexceeds a limit you set.
Memory is the other constraint to plan for. A Worker isolate is limited to 128 MB of memory, covering both the JavaScript heap and WebAssembly allocations, and one isolate can serve many concurrent requests. Request body limits are tied to your Cloudflare plan: 100 MB on Free and Pro, and 200 MB on Business. For ordinary webhook bodies this is irrelevant, but if you accept multi-megabyte batches, buffering them fully at high concurrency can exhaust the isolate.
7. Trade-offs and Practical Advice
Lifetimes add friction. Borrowed structs carry a lifetime parameter that spreads through every type that holds them, and they cannot outlive the input buffer. For code that needs to store or queue events, convert to owned types at the boundary.
WASM linear memory only grows. A WebAssembly instance can grow its memory but cannot shrink it. A Worker that handles one unusually large payload keeps that high-water mark for the life of the isolate. Since the 128 MB isolate limit includes WASM allocations, cap accepted body sizes explicitly.
Binary size matters. Workers that use WebAssembly are typically larger than equivalent JavaScript Workers, and larger Workers can take longer to start. Cloudflare documents size limits per plan and a one-second startup limit, and these limits have been revised over time, so check the current limits page before deploying. Cloudflare recommends running wasm-opt over your binary. The release profile shown earlier (opt-level = "z", LTO, a single codegen unit, panic = "abort") also helps. Compare binary size between a serde_json build and a simd-json build, since simd-json is a larger dependency.
Choose crypto crates with the target in mind. The ring crate is popular, but its documentation describes extra requirements for some functionality on wasm32 targets, including a C compiler for certain features. Pure-Rust RustCrypto crates avoid that toolchain complexity. Workers also expose the Web Crypto API to JavaScript, which is a valid alternative if you would rather keep hashing outside WASM.
Don’t skip the rest of webhook hygiene. Signature verification stops forgery; it does not stop duplicates. Providers deliver at least once, so deduplicate by event ID downstream. Keep the secret in a Worker secret binding, never in source, and support secret rotation by accepting multiple v1 signatures where the provider sends them.
Know when not to do this. If your webhook volume is modest, your payloads are small, and your origin is already fast, a plain JavaScript Worker that verifies the signature with Web Crypto and forwards the body may be simpler and good enough. Rust/WASM earns its complexity when you parse heavily at high volume, run close to the 10 ms CPU cap on the free tier, or want shared parsing logic between the edge and your Rust backend.
Conclusion
Rust, WebAssembly, and Cloudflare Workers can turn the edge from a pass-through proxy into a real intake layer: authenticate with HMAC, reject stale or forged traffic cheaply, and borrow rather than allocate when parsing what remains. The honest version of the zero-copy story is narrower than the marketing version. One memcpy still happens at the JavaScript boundary, escaped strings can force allocation, streaming cannot verify a signature early, and the real-world gain depends on your payloads. Build the variants, measure CPU time on real traffic, and let those numbers decide whether the added complexity pays for itself.
Originally published at https://instawebhook.com/blog/zero-copy-parsers-at-the-edge-processing-webhook-streams-with-rust-wasm-and-clou
Top comments (0)