all posts

The Best WebAssembly Runtimes in 2026 (From a MicroVM Vendor)

Ajay Kumar··11 min read

There is a genre of blog post I have learned to distrust: the one where a vendor patiently explains why the competing technology is not quite ready. I sell microVMs. So the honest version of this roundup opens by admitting that WebAssembly is, for a large class of workloads, simply the better answer than anything I ship -- and that I have a financial interest in you not believing that.

Conflict of interest on the table. I build PandaStack: Firecracker microVMs behind an API, a few hundred milliseconds to create one, a real Linux kernel per tenant. If your unit of work is a per-request plugin, a user-supplied expression, a policy filter or an edge function -- anything you run thousands of times a second with a fresh instance each time -- a microVM is the wrong shape and WebAssembly is the right one. Not "also viable". Right. Better on the first screen than three weeks into a migration.

This is a buyer's guide to the WASM runtimes themselves, not another WASM-versus-microVM argument. I have written those and linked them at the bottom; they answer a different question from the one you have once you have chosen WASM and need to pick what to embed.

It also tries to fix the commonest defect in these roundups: conflating runtimes with frameworks. Grading an engine and a plugin SDK side by side on "performance" produces a comparison you cannot act on, because one of them will change engines underneath you and the other is the engine.

Ground rules. Exactly one set of latency figures below has real numbers attached, and they are mine, measured on hardware I control. Every WASM project here is qualitative, because I am not going to publish benchmarks of other people's engines on my own blog. And this ecosystem moves faster than anything else in systems software: proposal names, ABI stability and which preview of WASI is current all changed while I was writing. Verify against each project's current documentation.

First, which of these things is a runtime

A WebAssembly runtime does three jobs. It prepares a module -- interpreting it, compiling it, or loading machine code somebody compiled earlier. It instantiates that module into a sandbox with linear memory, tables and a store. And it mediates every interaction with the outside world through an import table of host functions. Everything else here is a layer over one of those.

The distinction tells you where your risk lives. Embed a runtime and the boundary is code you chose plus host functions you wrote. Adopt a framework and you inherit its plugin-ABI opinions plus the security properties of whichever engine it wraps -- read about that separately, because the framework's README will not cover the engine's threat model. Wasmtime, Wasmer, WasmEdge, wazero, WAMR and V8's WASM path are runtimes; Extism is a framework; Spin and wasmCloud are platforms.

The four questions that actually decide it

Teams usually start with throughput benchmarks and relitigate the decision six months later, because throughput was the fourth most important variable. Here are the four, in the order they bind.

1. What is the compiled artifact, and who compiles it?

Four answers, on a triangle of startup latency, steady-state throughput and attack surface. You pick two corners and feel bad about the third.

  • A pure interpreter walks the bytecode. Startup is essentially free, footprint is tiny, and the trusted computing base is small enough that a careful team could read all of it. Steady-state execution is slower by a wide margin -- the factor depends on the workload, and I am not going to invent one.
  • A baseline or single-pass compiler emits mediocre machine code very fast. The sweet spot for short-lived untrusted modules: you will not amortise an optimising pass over four milliseconds of execution. Wasmtime's Winch and Wasmer's Singlepass are here.
  • An optimising JIT runs Cranelift or LLVM to produce good code. Correct for anything long-running or numeric, and the largest attack surface here by a comfortable margin.
  • Ahead-of-time compilation produces native code offline and the runtime becomes a loader. Startup approaches process-exec cost and nothing generates code at request time. WasmEdge and WAMR headline it; Wasmtime can serialise pre-compiled artifacts too.

Here is the part roundups skip, and it is a real security axis. A JIT is a code generator you are feeding attacker-controlled input. The attacker does not need to escape the sandbox semantically; they need an input that makes your register allocator or lowering rules emit code that does not match the module's declared semantics. Miscompilation bugs in a WASM compiler are sandbox escapes, and both Cranelift and the JS engines have had them found and fixed. That is what compilers are like -- but your engine choice decides how much compiler sits between a hostile module and your process.

Which is why the interpreter deserves more respect than it gets. If your plugins evaluate a policy expression or transform a JSON document, it is fast enough, and you have traded performance you will never notice for a trusted computing base small enough to audit.

AOT looks like it removes the problem and only relocates it. Somebody still compiles the attacker's module, and if users upload modules, that somebody is your build service. The win is that the optimising compiler is now a batch job you can run in a separate process, with a timeout, or inside a VM. Moving the code generator somewhere you can contain it is a real improvement; pretending it stopped existing is not.

2. What can the guest reach?

A bare WASM module can reach nothing: linear memory, tables, arithmetic, and whatever functions you imported. That is the appeal -- the sandbox is the default state rather than something you configure your way into, the opposite of every container runtime I have operated.

WASI is the standardised answer to "but it needs to read a file". Preview 1 -- the `wasi_snapshot_preview1` import module -- is a flat, POSIX-flavoured function set with one genuinely good idea in it: no ambient authority. There is no `open("/etc/passwd")`, because the guest can only act on descriptors the host preopened. Directories are granted, not discovered. It is implemented nearly everywhere, and frozen in an awkward shape with a thin networking story -- which is why several runtimes shipped incompatible socket extensions.

Preview 2 and the component model are a different design rather than an increment. You describe interfaces in WIT with real types -- records, variants, resources with methods, results that carry errors -- and a component declares a world: exactly what it imports and exports. The canonical ABI handles translation, so components in different languages compose without either knowing the other's memory layout. That composition property is the architecturally significant one.

Here is a capability-shaped interface designed on purpose. The guest never sees a path or a connection string -- only handles it was given.

// policy.wit -- the world a tenant's filter runs inside.
package pandastack:policy@0.1.0;

interface clock {
    /// Monotonic nanoseconds since this instantiation. Deliberately not wall
    /// time: the guest has no business knowing what year it is, and a clock
    /// is the cheapest covert channel there is.
    elapsed-ns: func() -> u64;
}

interface kv {
    /// An opaque handle, not a name. The guest cannot construct one of these;
    /// it can only use one it was handed, which is the entire point.
    resource store {
        get: func(key: string) -> option<list<u8>>;
        /// Fallible on purpose. Quota, key-prefix scoping and value size are
        /// enforced host-side; the guest's only observation is an error.
        put: func(key: string, value: list<u8>) -> result<_, string>;
    }
}

world policy-filter {
    import clock;
    import kv;

    /// The only export. No main, no _start: this is a reactor, instantiated
    /// per request and dropped afterwards.
    export evaluate: func(request: list<u8>) -> result<bool, string>;
}

Now the honest part, because it is what people misunderstand about WASM security. "No syscalls" means "no syscalls except the ones the host explicitly re-implements and hands over". Your host function table is the syscall table: every function in it runs with your process's full authority, and its bugs are your bugs rather than sandboxed ones.

This fails in a repeated, specific way. Somebody adds a convenience host function that takes a path string and returns bytes, because threading a preopened descriptor through the plugin API was annoying on a Friday. The sandbox now has an ambient filesystem read in it, and the guest need not escape anything -- it calls your function with `/proc/self/environ`. Same genre: a host function that fetches a guest-supplied URL, in a process with a metadata endpoint on a link-local address.

So this question is really two: how good is the runtime's WASI implementation, and how hard does it make writing a bad host function.

3. What does the language you actually use compile to?

The decisive practical constraint, and where most "we will just run customer code in WASM" plans quietly die. The question is not whether a language can target WASM, but whether the library-shaped reality of your workload survives the trip.

Rust is the first-class citizen and it is not close: the WASI targets are supported targets, the component tooling is written in Rust by the people writing the specs, and `no_std` gets you genuinely tiny modules.

Go has two answers and you need to know which you are getting. Real Go compiles to WASI directly, with goroutines, the garbage collector and most of the standard library intact -- at the cost of binaries measured in megabytes, because the runtime comes along. TinyGo produces modules smaller by orders of magnitude and is the usual plugin choice, in exchange for a different GC, less reflection and a standard-library subset that will reject something you depend on.

C and C++ compile well through the WASI SDK -- clang plus a WASI libc -- and that is the path for native code that is basically pure computation: parsers, codecs, compression, image processing, regex engines, geometry. Emscripten is the other toolchain and it is browser-shaped, emitting JavaScript glue and emulating POSIX, which is right in a tab and usually wrong on a server.

And then the part that sinks the plans. If the code you want to run is Python or JavaScript written by somebody else -- or by a model thirty seconds ago -- WASM is a poor fit, and I would rather be useful than diplomatic about it.

  • CPython has a WASI build, and it runs pure-Python code. What it lacks is the ecosystem, because the Python ecosystem is C extensions: numpy, pandas, psycopg, Pillow, cryptography, anything with a `.so` in the wheel. Those wheels do not exist for a WASI target, and "build it from source" means porting build systems.
  • Node does not fit through this hole at all. There is no supported WASI build, and the reason is instructive: Node is a JIT plus an event loop bound to platform I/O, and compiling that into a target whose premise is that code generation happens elsewhere is awkward. Small component-model JavaScript runtimes exist, but they are not Node.
  • Threads and shared memory are a live story rather than a settled one, with a gap between "the engine supports it" and "your language's runtime uses it correctly".
  • The long tail of native dependencies does not exist on the other side. Not "is slower" -- does not exist. No dynamically linked system libraries, no `dlopen` in the way your package expects, no ambient `/usr/lib`.

So, plainly: if the workload is "run the Python a language model just generated, with pandas, against a Postgres connection", WASM is the wrong tool. That is the conclusion this question produces, not a dig. The right tool is a sandbox with an unmodified userland and a package manager.

4. What stops a while(1), and what stops a 4 GiB allocation?

WASM gives you memory safety and isolation for free. It gives you resource control for exactly zero free. The guest runs on your thread, in your process, with your heap, and the kernel has no opinion about what it is doing, because from its point of view your process is simply busy.

So an infinite loop in a guest is stopped by a mechanism you opted into, or it is not stopped at all. That is the inverse of my world: in a microVM a runaway guest is bounded by `cpu.max`, and the worst case is a VM burning its own quota. In an in-process WASM host, the worst case is a plugin taking your service with it.

  • Fuel, or instruction metering. The compiler injects a decrement before each unit of work and traps when the budget is gone. Deterministic and precise -- wonderful for reproducibility and for billing -- and a permanent throughput tax on every instruction.
  • Epoch or timer interruption. A background thread bumps a counter that compiled code checks cheaply at loop back-edges. Near-zero steady-state cost, coarse granularity, wall-clock semantics -- what you want for "no plugin gets more than twenty milliseconds". Wasmtime calls it epoch interruption; wazero wires it to Go context cancellation.
  • Pre-emption by the host. Run the instance on a thread or worker you can kill. Blunt, effective, and much more expensive per call.

Memory is the easier half. Linear memory grows only through an instruction the host mediates, so a cap is a policy the engine enforces directly -- Wasmtime exposes a store-level resource limiter over memory pages, table elements and instance counts; wazero caps pages on the runtime config. Set it, and an allocating guest gets a failed `memory.grow` inside its own semantics rather than an OOM kill on your host. Cap the host side too: a host function allocating a buffer sized by guest input bypasses the guest's limit.

The last mechanism worth knowing is instance pooling: a pooling allocator pre-reserves instance slots with memory regions and guard pages already mapped, so instantiation becomes a reset rather than a syscall. Read very carefully about how state is scrubbed between tenants, because reuse is how cross-tenant leaks happen.

Here it is in Go with wazero, which makes the opt-in nature of all this unavoidably visible.

package plugin

import (
	"bytes"
	"context"
	"errors"
	"fmt"
	"io"
	"time"

	"github.com/tetratelabs/wazero"
	"github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1"
	"github.com/tetratelabs/wazero/sys"
)

// Compile once, at startup. Compilation is the expensive half; instantiation
// is the cheap half, and the cheap half is what belongs in the hot path.
func Compile(ctx context.Context, mod []byte) (wazero.Runtime, wazero.CompiledModule, error) {
	cfg := wazero.NewRuntimeConfig().
		// 256 pages x 64 KiB = 16 MiB of linear memory. A hard ceiling: an
		// allocating guest gets a failed memory.grow, not an OOM kill of us.
		WithMemoryLimitPages(256).
		// Inject interruption checks so a context deadline can actually stop
		// a running module. Without this, while(1) runs until the process
		// does, and nothing in the OS is coming to help you.
		WithCloseOnContextDone(true)

	// For an auditably small TCB, swap the config above for
	// wazero.NewRuntimeConfigInterpreter(): no code generation at all, at a
	// throughput cost you should measure on your own workload.

	r := wazero.NewRuntimeWithConfig(ctx, cfg)
	wasi_snapshot_preview1.MustInstantiate(ctx, r)

	compiled, err := r.CompileModule(ctx, mod)
	if err != nil {
		r.Close(ctx)
		return nil, nil, fmt.Errorf("compile: %w", err)
	}
	return r, compiled, nil
}

// Run instantiates a fresh module per call. No instance reuse across tenants:
// a pool is a performance feature and a cross-tenant leak waiting for a bug.
func Run(parent context.Context, r wazero.Runtime, compiled wazero.CompiledModule, in []byte) ([]byte, error) {
	ctx, cancel := context.WithTimeout(parent, 20*time.Millisecond)
	defer cancel()

	var stdout bytes.Buffer
	cfg := wazero.NewModuleConfig().
		WithName(""). // anonymous, so concurrent instances are legal
		WithArgs("filter").
		WithStdin(bytes.NewReader(in)).
		WithStdout(&stdout).
		WithStderr(io.Discard).
		// Capability-shaped filesystem: one read-only directory, visible to
		// the guest as /in. Nothing else on this host exists as far as it is
		// concerned -- not because a rule denies it, but because no
		// descriptor for it was ever handed over.
		WithFSConfig(wazero.NewFSConfig().
			WithReadOnlyDirMount("/srv/filter-input", "/in")).
		// Clocks and entropy are capabilities too. The default is a guest
		// that cannot tell the time.
		WithSysNanotime()

	mod, err := r.InstantiateModule(ctx, compiled, cfg)
	if err != nil {
		var exit *sys.ExitError
		if errors.As(err, &exit) && exit.ExitCode() == 0 {
			return stdout.Bytes(), nil // a command module that ran and exited
		}
		if errors.Is(ctx.Err(), context.DeadlineExceeded) {
			return nil, errors.New("filter exceeded its 20ms budget")
		}
		return nil, err
	}
	defer mod.Close(ctx)
	return stdout.Bytes(), nil
}

The runtimes, one at a time

Wasmtime

The Bytecode Alliance's reference-quality engine, in Rust, and my default for a new server-side embedding. Cranelift for optimised code, a baseline compiler for fast startup, the most complete component model and WASI preview 2 implementation here -- unsurprising, since the specs and the engine are built by overlapping sets of people -- and the richest operational story: fuel, epoch interruption, a store resource limiter, a pooling allocator. Its security engineering is the most visible too, including public work on verifying Cranelift's lowering rules. The catch: a lot of machinery, and non-Rust hosts go through the C API, a smaller surface than the crate.

Wasmer

Also Rust, also mature, and distinguished by taking pluggable backends seriously: single-pass when compile time dominates, Cranelift for the middle, LLVM when you want the best code and do not care how long you waited. Single-pass as a first-class choice rather than an afterthought is a genuine advantage for short-lived untrusted modules. Its most double-edged feature is WASIX, an extended POSIX-flavoured ABI with the threads, sockets and process primitives preview 1 lacks: the most direct route anyone offers for code that expects POSIX, and by construction not a standard, so a module built against it is portable across Wasmer and nowhere else.

WasmEdge

A CNCF project with a C++ core, aimed at cloud-native serving rather than plugins. Its headline is ahead-of-time compilation, which matters because AOT plus a small loader is the configuration with the least code generation at request time -- per question one, the smallest attack surface where it counts. It integrates with container tooling via OCI-shim style runtimes, so a WASM workload gets scheduled by the machinery already running your containers, and it has gone furthest on host-provided inference interfaces and socket extensions. The catch is the inverse of its strength: much of the appeal is extensions beyond the standards, so check what you are buying is portable.

wazero

The pure-Go runtime, whose main selling point is not about WebAssembly at all: it has no CGO. That one property means your Go project keeps cross-compiling to every target from one machine, `go test` keeps working, your image stays a static binary, and your build does not acquire a C toolchain. Anyone who has lost an afternoon to a cgo cross-compilation problem understands why teams pick it, and the answer is not benchmarks. It offers both an optimising compiler and a pure interpreter, which makes question one's trade a one-line config change. The catch is scope: Go hosts only, and a component model story that has trailed the Rust engines.

WAMR and iwasm

The WebAssembly Micro Runtime, also Bytecode Alliance, in C, and the right answer to a question none of the others is really asking: what if the host is a microcontroller. Its interpreter builds down to an embedded-sized footprint, there is a faster interpreter variant trading size for speed, AOT and JIT modes exist when the hardware justifies them, and there is execution-in-place for running from flash. Its interest for server-side readers is that it is the most explicit menu of execution models in the business: if you want to make the interpreter-versus-AOT choice deliberately and measure both, WAMR is built for it. The catch: a large build matrix and C ergonomics.

V8, and the WASM path you already have

If your host process is Node, Deno or Bun, you already have a production-grade WASM engine, and the cheapest correct decision may be to use it. V8 has a baseline compiler for fast startup and an optimising tier for hot code, maintained at a level of investment no standalone project matches. The catches, precisely: your trusted computing base is now an entire JavaScript engine including a speculative JIT; WASI is not the native interface, so your capability story is bespoke host functions; run the module in your application's isolate and your host functions are JavaScript closures with JavaScript reachability; and resource control is isolate-shaped -- terminate a worker, do not meter fuel.

Extism (a framework, not a runtime)

Extism is what most people actually want when they say they want a WASM runtime. It is a plugin framework: it defines the host-guest ABI, ships host SDKs for many languages and plugin development kits for the guest side, and handles passing byte buffers across the boundary so you are not hand-rolling memory marshalling. Underneath it uses a real engine -- Wasmtime by default -- so you inherit that engine's sandboxing properties. The case for it is time: building a plugin host from an engine API is a week you will spend mediocrely on marshalling. The catch is inherited: you are two layers from the engine, and the plugin ABI will live in your public API forever.

Spin and wasmCloud (application platforms)

The component-model-native application layer, competing with neither engine, because both run on one. Spin is a serverless framework in the shape the component model makes possible: write a component, declare a trigger, and the platform instantiates a fresh instance per request and drops it. Because instantiation is microseconds rather than milliseconds, per-request instantiation stops being a cost model to work around and becomes the normal thing. wasmCloud is the distributed version: components plus capability providers over a message lattice. The catch is ordinary adoption cost: you adopt an application model, portable within that ecosystem and not outside it.

Eight projects on the four questions, with my own substrate for contrast. Everything but the PandaStack row is qualitative, from public docs that move faster here than anywhere else -- verify before you buy.
ProjectExecution model(s)WASI / componentsEmbed fromResource limitsFits bestThe honest catch
Wasmtime (runtime)Optimising JIT, baseline JIT, AOT, interpreterMost complete preview 2Rust; C API plus bindingsFuel, epochs, store limiter, poolingDefault server-side embeddingNon-Rust hosts go via the C API
Wasmer (runtime)Single-pass, Cranelift or LLVM; AOTPreview 1 plus WASIXMany host languagesMetering via engine configShort-lived untrusted modulesWASIX is not a standard
WasmEdge (runtime)AOT-first, plus interpreterPreview 1 plus socket, inference extensionsC/C++, Rust, Go, NodeEngine memory, instruction limitsServing beside existing containersThe appeal is mostly non-standard
wazero (runtime)Optimising compiler or interpreter; zero CGOPreview 1 solid; verify componentsGo onlyPage cap, ctx cancellation, FS mountsGo hosts that cross-compileGo-only; numeric peak not the goal
WAMR / iwasm (runtime)Interpreter, fast interpreter, AOT, JIT, XIPPreview 1, embedded-orientedC/C++Build-time config; stack, heap boundsEmbedded; deliberate model choiceC ergonomics, large build matrix
V8 in Node/Deno/BunBaseline plus optimising tierNo native WASI; Node's experimentalJavaScript, already presentIsolate or worker termination; coarseSmall functions in a JS serviceTCB is a whole JS engine
Extism (framework)Whatever the engine doesInherited from the engineMany host SDKs; guest PDKsTimeouts, memory via the frameworkShipping a plugin system fastTwo layers out; ABI is permanent
Spin / wasmCloud (platform)Component instance per requestComponent-model-nativeYou write componentsPlatform-level, per instanceGreenfield component-first appsYou adopt an application model
PandaStack (microVM, contrast)Unmodified native code, Firecracker, Linux 5.10Not applicable -- a real kernelHTTP API, Python and TypeScript SDKscgroup cpu.weight, cpu.max; RAM fixed by snapshotToolchains, native deps, long jobsp50 179 ms: great per VM, hopeless per request

Where WASM genuinely beats a microVM

Conceding a point with specifics is the only concession worth anything, so here are mine. PandaStack creates a sandbox by restoring a baked Firecracker snapshot -- there is no warm pool of idle VMs, every create takes the restore path -- and that costs p50 179 ms and p99 203 ms end to end, including the TCP probe that proves the guest is answering. I am proud of that number: a complete Linux guest with its own kernel, on demand, in under a fifth of a second, with nothing held warm for it. It is also, for a per-request plugin, unusable. One hundred and seventy-nine milliseconds is an excellent VM create and a catastrophic function call, and no engineering on my side closes that gap, because the gap is a kernel.

The footprint difference is the same story in another unit. My `base` template is a 4 GiB guest; a WASM instance costs its linear memory plus a little bookkeeping, kilobytes for a small module. One process can hold tens of thousands of live instances. One host cannot hold tens of thousands of microVMs.

  • Startup in the microsecond-to-millisecond range, which makes per-request instantiation the default rather than something you build a pool to avoid. It is what makes the component-model platforms possible at all.
  • Per-instance footprint in kilobytes. Tens of thousands of live instances in one process is an ordinary deployment, not a stunt.
  • Deterministic execution by default, which nothing else here offers. Same module, same input, same result -- modulo NaN bit patterns, some SIMD, and anything you import, which is the point: nondeterminism arrives through the import table you control.
  • A tiny trusted computing base, if you choose an interpreter -- a boundary a small team can genuinely reason about. Mine is a hypervisor plus a guest kernel: a stronger wall, and not one anybody is going to read.
  • Real portability of one artifact: the same module runs on a server, at an edge PoP, in a browser tab and on a microcontroller. A Firecracker snapshot runs on a Linux KVM host and nowhere else.

For a user-supplied expression evaluator, a per-request policy filter, a rules engine, a template transform, or a plugin marketplace where the plugins are small and numerous -- WASM is right and a microVM is a category error.

Where the microVM wins, and why

WASM isolates a function; a microVM isolates a machine. If the thing you have to contain is a whole toolchain rather than a computation, you need the machine.

  • An unmodified userland. `apt-get`, `pip install`, `npm ci`, a Dockerfile, a shell script somebody wrote in 2019 that still works. None of this has a WASM translation, and "port it" is a project, not a configuration.
  • A real kernel. Mount namespaces, cgroups, iptables, procfs, loopback devices, whatever your integration test does with network interfaces. If the code under test is systems code, it needs a system.
  • Native dependencies -- the commonest reason teams come back. If the dependency graph contains a `.so`, the question is answered.
  • Long-running CPU-saturating work: a thirty-minute build, a test matrix, a render, a solver. Create latency amortises to nothing, and what you want is full-speed native execution under a hard ceiling -- `cpu.max` on a per-VM cgroup for me -- rather than a metering tax on every instruction.
  • A hostile-by-assumption workload with an unbounded shape. When you cannot enumerate what the code will try, a boundary enforced by hardware rather than by a code generator is much easier to explain to a security reviewer. A model-generated `rm -rf /` is a funny log line in a guest that evaporates in 200 ms, and a career event on a shared host kernel.

The hybrid, and how to decide which half a workload is

Almost every mature system I have seen here runs both, not as a compromise but because it has two genuinely different workloads and stopped pretending they were one. WASM on the per-request hot path: the filter, the transform, the hook, the thing that runs ten thousand times a minute and must cost nothing. A microVM on the cold path: the build, the report, the agent's actual work, the thing that needs a package manager.

The decision rule is two sentences. If the unit of work is shorter than the time it takes to create an isolate, the isolate has to be WASM -- the arithmetic permits nothing else. If it needs a package manager, a kernel feature, or a dependency with a `.so` in it, it has to be a VM. Everything in between is a judgement call, and the tiebreaker is whose code it is: your own is cheap to adapt to a component interface, and a customer's is not.

Here is the cold half -- the monthly report the hot-path filter could never run, because its dependency list is a list of compiled extensions.

import json

from pandastack import Sandbox


def monthly_report(tenant: str, script: bytes, requirements: str) -> dict:
    """The cold half of the hybrid.

    The hot half ran this tenant's policy filter as a Wasm component, in
    process, ten thousand times today, for approximately no money. This runs
    once a month and wants pandas, pyarrow and psycopg -- every one of which
    ships a .so, which is exactly why this half is a VM.
    """
    sbx = Sandbox.create(
        template="code-interpreter",      # 2 GiB / 8 vCPU, Python 3.12
        ttl_seconds=1800,                 # a platform-side backstop, not a
        metadata={                        # substitute for the finally block
            "tenant": tenant,
            "kind": "monthly-report",
        },
    )
    try:
        sbx.filesystem.write("/work/requirements.txt", requirements.encode())
        sbx.filesystem.write("/work/report.py", script)

        sbx.exec(
            "cd /work && python3 -m venv .venv "
            "&& .venv/bin/pip install -q -r requirements.txt",
            timeout_seconds=600,
            check=True,
        )

        r = sbx.exec(
            "cd /work && .venv/bin/python report.py",
            timeout_seconds=900,
        )
        if r.exit_code != 0:
            raise RuntimeError(
                "report failed (%d): %s" % (r.exit_code, r.stderr[-2000:])
            )

        return json.loads(sbx.filesystem.read("/work/out.json"))
    finally:
        # The TTL would get it eventually. "Eventually" is how you end up
        # explaining a line item to your own finance team with a straight face.
        sbx.kill()

Note what each half pays. The WASM side pays in porting effort and in a boundary made of your own host functions. The VM side pays 179 ms, and bills at $0.054 per vCPU-hour and $0.0162 per GiB-hour while alive -- a rounding error for a monthly job, an embarrassment for a per-request filter.

What I would actually pick

Rust host, server-side, component model in the plan: Wasmtime, and spend the afternoon on its resource-limiter and epoch documentation rather than its benchmarks. Go host: wazero, for the cgo reason alone, and try the interpreter config before assuming you need the compiler. Already in Node: use the engine you have, and write down that the trusted computing base is a JavaScript engine so the decision stays revisitable. Embedded, or you want the execution-model choice explicit: WAMR. Serving beside containers: WasmEdge. You want a plugin system rather than an engine: Extism, having read the engine's docs. Greenfield and you believe in components: Spin or wasmCloud.

And if what you actually have is somebody else's Python with somebody else's dependencies: none of the above, and I would say so even though I sell the alternative. The test I apply to my own writing is whether the post is still useful to someone who then picks my competitor. This one should be.

Frequently asked questions

Is WebAssembly a secure sandbox for running untrusted code?

Yes, with a caveat that decides most real incidents. The execution model is strong: linear memory is bounds-checked, there is no pointer arithmetic that reaches outside it, control flow is structured, and a module with no imports can reach nothing at all. The sandbox is the default state rather than something you configure your way into, which is the opposite of a container. The caveat is that a module with no imports also cannot do anything useful, so every real deployment hands the guest host functions -- and that import table is the guest's syscall table. Those functions run with your process's full authority, and their bugs are ordinary bugs in your application, not sandboxed ones. The recurring failure is a convenience host function that accepts a path, a URL or an array index and does not treat it as hostile input. The second caveat is the engine: an optimising JIT is a code generator consuming attacker-controlled input, and a miscompilation is an escape.

Can I run Python or Node.js inside a WebAssembly sandbox?

Partly, and the missing part is usually the part you needed. CPython has a WASI build that runs pure-Python code, and it is real, working engineering. What does not come with it is the ecosystem, because the Python ecosystem is C extensions: numpy, pandas, psycopg, Pillow, cryptography, anything whose wheel contains a shared object. Those builds do not exist for a WASI target, and producing them yourself means porting build systems rather than flipping a flag. Browser-oriented distributions ship curated builds of part of the scientific stack, but that is a different product aimed at a tab, not a runtime you can hand an arbitrary requirements file. Node is a harder no: there is no supported WASI build, because Node is a JIT plus an event loop bound to platform I/O, which is awkward to compile into a target whose premise is that code generation happens elsewhere.

How do I stop a WebAssembly module from running forever or eating all my memory?

By opting into a mechanism, because nothing stops it otherwise. The guest runs on your thread in your process, and the operating system sees only that your process is busy, so an infinite loop in a guest is an infinite loop in your service. There are three families. Instruction metering, which Wasmtime calls fuel, injects a decrement before each unit of work and traps when the budget runs out: deterministic and precise, excellent for reproducibility and billing, and a permanent throughput tax. Epoch or timer interruption has a background thread bump a counter that compiled code checks cheaply at loop back-edges: near-zero cost, coarse granularity, and the right default for "no plugin gets more than twenty milliseconds". In Go, wazero wires that to context cancellation once you enable interruption checks. The third is pre-emption: run the instance somewhere you can kill it. Memory is easier -- linear memory grows only through an instruction the host mediates, so cap the page count, and cap the host side too.

What is the difference between WASI preview 1 and the component model?

Preview 1 is a flat set of POSIX-flavoured functions imported under the name wasi_snapshot_preview1, with one genuinely good idea in it: no ambient authority. There is no open call taking an arbitrary path, because the guest can only act on descriptors the host preopened, so directories are granted rather than discovered. It is implemented nearly everywhere and it is what almost every existing toolchain targets, which makes it the pragmatic choice today. It is also frozen in an awkward shape with a thin networking story, which is why several runtimes shipped incompatible socket extensions. Preview 2 and the component model are a different design rather than an increment. You describe interfaces in WIT with real types -- records, variants, resources with methods, results that carry errors -- and a component declares a world stating exactly what it imports and exports. The canonical ABI handles translation, so components written in different languages compose without either knowing the other's memory layout.

Should I use WebAssembly or a microVM for running AI-generated code?

A microVM, in almost every case, and I will show my work because I sell them. The question is not which boundary is stronger -- both are strong. It is what the code expects to find. AI-generated code is written against the ecosystem the model learned from: it imports pandas, it shells out, it reads files, it installs a package it just thought of, and occasionally it writes something catastrophic to a path it should not. WASM can contain all of that beautifully and cannot run most of it, because the native dependencies do not exist for the target and nothing in the sandbox resembles the Linux the code assumes. You would spend your time porting rather than isolating. A microVM gives you an unmodified userland and a real kernel, so generated code fails for its own reasons rather than for substrate reasons. Where WASM wins in an agent architecture is the hot path around the model: tool-call validation, output filtering, policy evaluation, scoring.

Keep reading

Related posts

  • WebAssembly vs Firecracker for Untrusted Code

    WASM asks "may I?" before every syscall; Firecracker hands you a whole Linux guest behind a hardware wall. For untrusted code the real question isn't which is safer — it's which can run the workload at all.

  • Server-Side Rendering Someone Else's React Component

    If your product server-renders components your customers wrote, you are running their code in your process, with your env vars and your database socket. node:vm is a sandbox for values, not for a module graph that can require("fs"). Here is the boundary that actually holds, and what it costs.

  • Firecracker vs WebContainers: where should untrusted code run?

    One runs code inside the user's browser tab and costs you nothing. The other runs a real guest kernel on a machine you pay for. The choice comes down to two questions: does the code need real Linux, and is there a human browser in the loop?

  • Firecracker vs CheerpX / WebVM: where does the code run?

    CheerpX/WebVM runs a whole Linux distro inside the browser tab with zero backend; Firecracker runs a real microVM on your server. They solve opposite problems — here's the honest split.

  • Running Customer Git Hooks in Isolated microVMs

    A git hook is a shell script your user wrote that your server agreed to run. The exploit is not the scary part — the scary part is a policy hook that greps a 4 GB monorepo on every push and takes the whole push queue with it.

More in Code execution · See Code interpreter sandboxes on PandaStack

Run code in a microVM in one API call.

49ms p50 cold start. Fork, snapshot, and scale to zero.

Start free
Written by Ajay Kumar, Founder, PandaStack.