Skip to content

WebAssembly at the edge: transform API traffic without a server

How WebAssembly modules can rewrite API requests and responses inline: sandboxing, a minimal ABI, time and memory budgets, and choosing fail-open or fail-closed.

The ProxifyEdge team 5 min read

Plenty of small jobs need code between a browser and an API: adding a header the browser is not allowed to set, stripping a field before it reaches the page, rejecting requests that do not match a rule, or reshaping a response into the format your frontend expects. Traditionally each of those meant a server. WebAssembly at the edge offers another option: upload a small, sandboxed module that runs inline on every request and response, without a server of your own.

This guide explains why WebAssembly suits this job, what a minimal interface between the proxy and a module looks like, which budgets keep it safe, and how to choose between failing open and failing closed.

Why WebAssembly fits inline transformation

Running someone else’s code inside a proxy is only acceptable if that code cannot do anything you did not intend. WebAssembly has properties that make that tractable:

  • A sandbox by default. A WebAssembly module can only touch its own linear memory and the functions its host explicitly provides. It has no filesystem, network or clock unless the host imports them.
  • Many source languages. Rust, Go (TinyGo), C, AssemblyScript and others compile to WebAssembly, so the transform can be written in whatever the team knows.
  • Predictable resource control. The host decides how much memory an instance gets and can stop execution when a time budget runs out.
  • Fast startup. A compiled module can be instantiated quickly, so a fresh instance per request is affordable.

That last point enables a strong isolation rule: a fresh instance for every request. No state survives from one request to the next, so one tenant’s data cannot leak into another’s, and a module cannot build up a cache that behaves differently over time.

A minimal ABI: JSON in, JSON out

The interface between the proxy and a module, its ABI, can be very small. A module that imports nothing and exports:

(import)  none
(export)  memory
(export)  alloc(size: i32) -> i32
(export)  on_request(ptr: i32, len: i32)  -> i64   ; optional
(export)  on_response(ptr: i32, len: i32) -> i64   ; optional

The host calls alloc to reserve space, writes a JSON document describing the request into the module’s memory, and calls on_request with its location. The hook returns a pointer and length packed into one 64-bit value, (ptr << 32) | len, naming a JSON document it wrote back, or 0 for “no change”. on_response works the same way for the upstream’s answer.

The documents describe what the module may see and what it may change:

// on_request receives
{ "method": "GET", "target": "https://api.example.com/x", "headers": { "accept": "application/json" }, "body": "" }

// and may return any of
{ "set_headers": { "x-tenant": "acme" }, "remove_headers": ["cookie"],
  "target": "https://api.example.com/v2/x", "reject": { "status": 403, "body": "not allowed" } }

JSON is not the most efficient encoding, but it is easy to produce from every language that targets WebAssembly, easy to log while debugging, and it keeps the ABI stable as fields are added.

What a module can usefully do

With that interface, typical edge modules include:

  • Header shaping. Add a tenant header, remove cookies before they reach an upstream, or set Cache-Control on responses.
  • Routing. Rewrite the target to a regional or versioned endpoint based on a header or path.
  • Policy. Reject requests that do not meet a rule, such as a missing parameter or a method you do not allow, with a clear status and message.
  • Response filtering. Remove fields the browser should never see from a JSON response, or normalize an awkward upstream format.

Whatever the module returns, the proxy should re-apply its own safety rules. In particular, a rewritten target must pass the same checks as any other destination, so a module cannot redirect traffic to an internal address. The SSRF guide explains why that check is not optional.

Budgets: time, memory and size

Inline code runs on the request path, so every millisecond it takes is latency your users feel. Hard limits keep a slow or malicious module from hurting anyone else:

  • Time per hook. A budget measured in tens or low hundreds of milliseconds, enforced by the runtime. A module stuck in a loop is stopped, not waited for.
  • Memory per instance. A ceiling on linear memory, so a module cannot allocate its way into exhausting the host.
  • Module size. Compilation cost grows with module size, so cap uploads.
  • Payload size. Limit how much JSON goes in and out, and pass very large bodies through untouched rather than copying them into the sandbox.

Keep modules small and single-purpose. A module that does one transformation is easier to test, faster to compile and simpler to reason about when something goes wrong.

Fail-open or fail-closed?

Sooner or later a module will trap, exceed its budget or return invalid JSON. The proxy then has two choices:

  • Fail open: log the failure and pass the request or response through unchanged. Users are not affected by a buggy transform, but the transform’s effect is lost for that request.
  • Fail closed: reject the request. Nothing goes through without the module’s approval.

The right default depends on what the module does. A module that reshapes data, adds convenience headers or improves caching should fail open: a broken nicety should not take your app down. A module that enforces policy, such as blocking certain requests or stripping sensitive fields, must fail closed, because failing open would silently disable the control. Make the choice explicit per module rather than relying on a global default.

Testing edge modules

Because the ABI is just JSON over memory, modules are easy to test outside the proxy. Write unit tests in the source language that call your hook function with sample request documents and assert on the returned JSON. Then test end to end through the proxy with a test key, and record the upstream responses with record and replay so the run is deterministic.

Edge modules in ProxifyEdge

ProxifyEdge supports this model for requests sent through it, with the ABI shown above. Edge modules run customer code, so they are off unless the deployment’s operator enables them; if the Edge modules page in the dashboard says they are unavailable, they are not enabled for that service.

When enabled, a module is uploaded per API key and runs inline on that key’s traffic. Modules can be up to 8 MiB, get 32 MiB of memory, exchange up to 4 MiB of JSON, and have a 100 ms budget per hook by default. Each request gets a fresh instance, and a rewritten target is checked against the same address rules as any other request. Failing modules let the request through unchanged by default; upload with ?fail_open=false for a module that enforces policy. The edge modules docs cover payloads and limits in full.

Key takeaways

  • WebAssembly’s sandbox and explicit imports make inline, multi-tenant code tractable.
  • A JSON-in, JSON-out ABI with optional on_request and on_response hooks is enough for most transforms.
  • Instantiate per request, cap time, memory and sizes, and re-check every rewritten destination.
  • Fail open for convenience transforms; fail closed for anything that enforces policy.
  • Keep modules small, single-purpose and unit-tested against sample JSON documents.

Are you sure?