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:

  1. the guest SDK used by the Rust project;
  2. the Proxy-Wasm ABI expected by the module;
  3. 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.