Envoy can run WebAssembly modules at several extension points. For HTTP traffic, the useful mental model is a filter, not a database-style user-defined function: Envoy invokes lifecycle callbacks as a request and response move through the HTTP filter chain, and the module may inspect metadata, mutate headers, pause processing, or produce a local response.
This distinction matters. A Proxy-Wasm module is not an arbitrary Rust program with direct sockets and threads. It is a guest component behind the Proxy-Wasm ABI. Envoy owns networking, scheduling, and most asynchronous work; the guest calls host functions exposed by the SDK.
The example below validates an optional request-cost header. It is small enough to understand, but it also exposes the boundaries that production filters must respect.
The execution model
A configured HTTP extension creates:
- a root context for VM- or plugin-level lifecycle and configuration;
- an HTTP context for each request or stream;
- callbacks for events such as request headers, request body, response headers, and stream completion.
Worker threads run isolated Wasm execution contexts. A callback runs inline with Envoy's request processing, so expensive computation delays traffic on that worker. Network or timer work is requested through Envoy host calls and completed through later callbacks; blocking I/O inside the module is the wrong design.
The Rust SDK turns the ABI into Rust traits. Application code implements Context plus RootContext or HttpContext. It does not implement Envoy's host functions.
A minimal Rust filter
Create a library crate with a WebAssembly-compatible artifact:
[package]
name = "request-cost-filter"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[dependencies]
proxy-wasm = "0.2"
The filter accepts a missing x-request-cost header, accepts an integer from 0 through 1000, and rejects malformed or excessive values:
use proxy_wasm::traits::*;
use proxy_wasm::types::*;
proxy_wasm::main! {{
proxy_wasm::set_log_level(LogLevel::Info);
proxy_wasm::set_root_context(|_| Box::new(FilterRoot));
}}
struct FilterRoot;
impl Context for FilterRoot {}
impl RootContext for FilterRoot {
fn get_type(&self) -> Option<ContextType> {
Some(ContextType::HttpContext)
}
fn create_http_context(
&self,
context_id: u32,
) -> Option<Box<dyn HttpContext>> {
Some(Box::new(RequestContext { context_id }))
}
}
struct RequestContext {
context_id: u32,
}
impl Context for RequestContext {}
impl HttpContext for RequestContext {
fn on_http_request_headers(
&mut self,
_num_headers: usize,
_end_of_stream: bool,
) -> Action {
let Some(raw) = self.get_http_request_header("x-request-cost") else {
return Action::Continue;
};
let Ok(cost) = raw.parse::<u32>() else {
self.send_http_response(
400,
vec![("content-type", "text/plain")],
Some(b"invalid x-request-cost"),
);
return Action::Pause;
};
if cost > 1000 {
self.send_http_response(
429,
vec![("content-type", "text/plain")],
Some(b"request cost exceeds limit"),
);
return Action::Pause;
}
Action::Continue
}
}
The code is intentionally boring. It reads one header, parses it, and either lets the request continue or sends a local response.
That is a good property for a data-plane extension. A filter that sits on every request should have an execution path that is easy to bound and observe.
Root state and request state are different scopes
The root context is useful for plugin-level configuration and shared lifecycle events. A request context should contain only state for one HTTP stream.
Conceptually:
Wasm VM / plugin
|
+--> RootContext
|
+--> HttpContext request A
+--> HttpContext request B
+--> HttpContext request C
Putting per-request data into shared root state creates unnecessary synchronization and makes request lifetime harder to reason about. Putting long-lived configuration into every request context wastes work and memory.
Keep state at the narrowest scope that owns it.
Configure Envoy to load the module
The exact configuration shape changes across Envoy releases, so pin the Envoy version and consult its Wasm filter schema. The high-level structure is stable: insert a Wasm HTTP filter into the HTTP filter chain and provide a VM/plugin configuration pointing at the compiled module.
A simplified configuration looks like:
http_filters:
- name: envoy.filters.http.wasm
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.wasm.v3.Wasm
config:
name: request_cost
root_id: request_cost_root
vm_config:
vm_id: request_cost_vm
runtime: envoy.wasm.runtime.v8
code:
local:
filename: /etc/envoy/request_cost_filter.wasm
- name: envoy.filters.http.router
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
Filter order matters. A module placed before authentication sees traffic that has not yet been authenticated. A module placed after routing-related filters may not observe the same state. Treat the HTTP filter chain as an execution pipeline, not a bag of middleware.
Callbacks run on Envoy's request path
The most important production rule is simple:
Do not block an Envoy worker thread from a Wasm callback.
A callback that spends 50 ms on CPU or synchronous I/O does not merely make its own request slower. It can delay unrelated requests scheduled on that worker.
Avoid:
- blocking filesystem calls;
- synchronous network calls;
- unbounded parsing;
- large allocations per request;
- expensive cryptography without a clear budget;
- loops whose input size is controlled by an untrusted caller.
If the filter needs external information, use the host capabilities and asynchronous callback model supported by the Proxy-Wasm environment rather than opening sockets from guest code.
Pausing a request creates a state machine
An asynchronous filter may need to stop request processing while it waits for a host operation.
The mental model becomes:
request headers
|
v
start async host call
|
v
Action::Pause
|
| later callback
v
validate result
|
+--> continue request
|
+--> local response
Once a filter pauses traffic, it owns additional responsibilities:
- remember which operation belongs to which request;
- bound the wait with a timeout;
- handle host-call failure;
- handle stream cancellation;
- decide whether failure should allow or reject the request;
- release per-request state on completion.
This is why even a tiny network lookup turns a header filter into a distributed-systems component.
Fail-open versus fail-closed is a product decision
Suppose a filter calls an authorization or policy service and that dependency becomes unavailable.
Two broad strategies exist:
fail closed -> reject because policy could not be verified
fail open -> allow because policy infrastructure failed
Neither is universally correct.
Authentication and security enforcement often need fail-closed behavior. Telemetry enrichment or an optional experiment may be better served by fail-open behavior. The choice should be explicit in configuration and observable in metrics.
Do not let an SDK's default error path silently make the policy decision.
ABI compatibility is part of deployment
A compiled .wasm module sits between three moving pieces:
- the guest SDK used by the Rust project;
- the Proxy-Wasm ABI expected by the module;
- the Envoy build and Wasm runtime that hosts it.
Treat those versions as one compatibility matrix. Pin them in CI, build the module reproducibly, and test it against the same Envoy version used in production.
A module compiling successfully does not prove that every host call behaves as expected in a different Envoy release.
Keep memory bounded
Wasm gives stronger isolation than loading arbitrary native code into Envoy, but resource exhaustion is still possible.
A filter can allocate too much memory, retain request state for too long, or create too many outstanding host operations. Apply limits at multiple layers:
- cap request/header/body data the filter inspects;
- avoid copying complete bodies unless necessary;
- bound maps and caches stored in root state;
- set host-call timeouts;
- expose counters for active contexts and outstanding calls;
- use runtime memory or execution limits supported by the host.
Isolation is useful only if one plugin cannot exhaust the process that isolates it.
Observability should distinguish filter outcomes
For the request-cost example, useful counters would include:
filter.requests_total
filter.header_missing_total
filter.accepted_total
filter.invalid_header_total
filter.over_limit_total
filter.local_response_total
filter.callback_errors_total
Latency histograms for filter execution and any host calls are equally important.
Avoid high-cardinality labels such as raw user IDs or request IDs in metrics. Correlation IDs belong in tracing or logs, not as an unbounded metric dimension.
Testing the module
I like to test a data-plane extension at three layers.
Pure Rust logic
Move parsing and policy decisions into ordinary Rust functions where possible:
fn validate_cost(raw: Option<&str>) -> Result<(), &'static str> {
let Some(raw) = raw else { return Ok(()); };
let cost = raw.parse::<u32>().map_err(|_| "invalid")?;
if cost > 1000 {
return Err("over limit");
}
Ok(())
}
These tests are fast and do not need Envoy.
Wasm/host integration
Verify that callbacks read and mutate headers, send local responses, and resume paused requests correctly through the Proxy-Wasm ABI.
Real Envoy integration
Run the module in the pinned Envoy image and send actual HTTP traffic through the configured filter chain. This catches configuration, ABI, runtime, and filter-order mistakes that unit tests cannot.
When Proxy-Wasm is the right extension point
A Wasm HTTP filter is a good fit when behavior belongs on Envoy's traffic path and needs stronger isolation or portability than a native extension.
Examples include:
- request metadata validation;
- custom authentication adapters;
- header normalization;
- policy enforcement;
- experiment or routing metadata;
- lightweight telemetry enrichment.
It is a poor fit for heavy business logic, large data transformations, or blocking workflows that should live in an application service.
The decision boundary is architectural:
traffic policy that must run with Envoy -> filter
business workflow and durable state -> service
The useful Rust model
The Rust SDK makes a host/guest protocol feel like ordinary traits, but the underlying execution model still matters.
A robust filter keeps three ideas explicit:
- scope: root state versus per-request state;
- scheduling: callbacks execute on Envoy's data path and must stay bounded;
- ownership: paused streams and asynchronous host calls create state that survives the original callback.
Once those boundaries are clear, Proxy-Wasm becomes a practical way to extend Envoy without turning the proxy into an application server.