WebAssembly Outside the Browser: WASI and Server-Side Wasm

1. Overview

WebAssembly (Wasm) was born to run high-performance code in the browser. But its characteristics (compact size, tight sandbox, CPU architecture-independent) are also very appealing on the server side.

In this article, you’ll understand what WASI is and why server-side Wasm is gaining attention. You’ll build a Wasm module, run it via command-line runtime, embed it in a Java 21 application, and write tests for it. At the end are cases where you should not use Wasm.

2. Why does WebAssembly need WASI?

2.1. Bare Wasm can’t do anything with the outside world

A pure Wasm module can only compute over its own linear memory. It cannot open files, read environment variables, get system time, or open sockets by itself. Every interaction with the outside must go through functions the host (the environment running the module) provides as imports.

In a browser, the host is JavaScript. Outside a browser, there’s no unified host yet. If each runtime defined its own API set, a module built for one runtime wouldn’t run on another.

2.2. WASI: A standard system interface for Wasm

WASI (WebAssembly System Interface) is a standard API set for OS-like operations: files, clock, random numbers, environment variables, command-line arguments, and in newer versions, networking. A module compiled targeting WASI can run on any runtime that supports WASI, such as Wasmtime, WasmEdge, Wasmer, or a runtime embedded in your application.

The most important difference is the capability model. A module has no permissions by default. The host must grant permissions explicitly, for example, “allowed to read the ./data directory”. Without permission, the call fails. This is the opposite of a typical process, which inherits all the permissions of the user running it.

2.3. WASI Preview 1, Preview 2, and Component Model

WASI has gone through many stages. You’ll encounter two common names:

  • Preview 1 (wasip1): C-like function API, most widely supported.
  • Preview 2 (wasip2): Built on the Component Model. Interfaces are described using an IDL called WIT. Components written in different languages can call each other over richer data types like strings, records, and lists, instead of just integers.

Support levels differ by runtime and toolchain. Before choosing, check your runtime’s documentation.

3. Why is server-side Wasm getting attention?

There are four main reasons:

  1. Sandboxing by default: A module cannot access memory outside its own region and has no system permissions unless granted. This is ideal for running untrusted code, like customer plugins or user-uploaded scripts.
  2. Fast startup, small footprint: Modules are usually lightweight and initialize much faster than a container or a new JVM. That’s why Wasm fits serverless and edge, where each request might need its own instance.
  3. Portable: A single .wasm file runs on x86 or ARM, Linux or macOS, as long as there’s a runtime.
  4. Multi-language: Rust, C/C++, Go, Zig, AssemblyScript… can all compile to Wasm. Your Java application can load logic written in another language without JNI.

Quick comparison with containers:

Criterion Container Wasm + WASI
Packaging unit Image containing userland OS Single .wasm file
Isolation Kernel namespace/cgroup Runtime sandbox + capability
Architecture-dependent Needs image per arch No
Ecosystem maturity Very mature Still developing

Wasm doesn’t replace containers. The two often complement each other: you can still run a Wasm runtime inside a container.

4. Building and running a WASI program

Example uses Rust since Rust’s Wasm toolchain is convenient. The idea applies the same to other languages.

4.1. A simple order file reader

// src/main.rs
use std::fs;

fn main() {
    match fs::read_to_string("orders.csv") {
        Ok(content) => {
            let total: f64 = content
                .lines()
                .filter_map(|line| line.split(',').nth(1))
                .filter_map(|amount| amount.trim().parse::<f64>().ok())
                .sum();
            println!("Total order value: {total}");
        }
        Err(e) => eprintln!("Could not read file: {e}"),
    }
}

The program reads orders.csv, extracts the second column (amount), and sums it up. The code has nothing special for Wasm: std::fs will be mapped to WASI calls when compiled.

Add the target and build:

rustup target add wasm32-wasip1
cargo build --release --target wasm32-wasip1

The result is the file target/wasm32-wasip1/release/order-report.wasm (named after your package).

4.2. Run with Wasmtime and see capabilities in action

Create a data file:

ORD-1,120.5
ORD-2,79.5
ORD-3,300

Run without granting directory permission:

wasmtime run target/wasm32-wasip1/release/order-report.wasm
Could not read file: failed to find a pre-opened file descriptor through which "orders.csv" could be opened

The module runs but sees no files. It has no capability over the filesystem. Now grant directory permission:

wasmtime run --dir=. target/wasm32-wasip1/release/order-report.wasm
Total order value: 500

The --dir flag “pre-opens” the directory for the module. It only sees that directory, not /etc or your home directory. The error message might differ between runtime versions, but the behavior is the same.

Note: The flag syntax (--dir, --env, directory mapping) may change between Wasmtime versions. Check wasmtime run --help on your machine.

5. Embedding Wasm in a Java application

Server-side Wasm isn’t just running .wasm files from the CLI. A very practical way is to embed a runtime in your existing application to run plugins or logic provided by third parties.

With Java, there are two approaches:

  • Binding to a native runtime (Wasmtime, WasmEdge) via JNI or FFM.
  • Using a runtime written entirely in Java, such as Chicory. No native libraries needed, so it’s easier to package and runs anywhere with a JVM.

The example below uses Chicory.

5.1. A module for calculating shipping fees

Suppose a partner provides shipping calculation logic as Wasm:

// src/lib.rs, Cargo.toml has: [lib] crate-type = ["cdylib"]
#[no_mangle]
pub extern "C" fn shipping_fee(weight_grams: i32, distance_km: i32) -> i32 {
    let base = 15_000;
    let by_weight = (weight_grams / 500) * 5_000;
    let by_distance = distance_km * 1_000;
    base + by_weight + by_distance
}
rustup target add wasm32-unknown-unknown
cargo build --release --target wasm32-unknown-unknown
cp target/wasm32-unknown-unknown/release/shipping.wasm src/main/resources/

This module doesn’t use WASI. It only computes, so the wasm32-unknown-unknown target is enough and the module doesn’t need any imports.

5.2. Maven dependency

<properties>
    <java.version>21</java.version>
    <!-- Check Maven Central for the latest version -->
    <chicory.version>REPLACE_WITH_LATEST</chicory.version>
</properties>

<dependencies>
    <dependency>
        <groupId>com.dylibso.chicory</groupId>
        <artifactId>runtime</artifactId>
        <version>${chicory.version}</version>
    </dependency>
    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.assertj</groupId>
        <artifactId>assertj-core</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

If using Spring Boot 3.x, JUnit and AssertJ versions are already managed via spring-boot-starter-test.

5.3. Call a Wasm function from Java

package dev.nonla.shipping;

import com.dylibso.chicory.runtime.ExportFunction;
import com.dylibso.chicory.runtime.Instance;
import com.dylibso.chicory.wasm.Parser;

import java.io.InputStream;

public class WasmShippingCalculator {

    private final ExportFunction shippingFee;

    public WasmShippingCalculator(String resource) {
        try (InputStream wasm = getClass().getResourceAsStream(resource)) {
            var module = Parser.parse(wasm);
            Instance instance = Instance.builder(module).build();
            this.shippingFee = instance.export("shipping_fee");
        } catch (Exception e) {
            throw new IllegalStateException("Could not load module " + resource, e);
        }
    }

    public int fee(int weightGrams, int distanceKm) {
        long[] result = shippingFee.apply(weightGrams, distanceKm);
        return (int) result[0];
    }
}

Explanation:

  • Parser.parse(...) reads Wasm bytecode into a module model.
  • Instance.builder(module).build() creates an instance with its own memory. Since the module doesn’t import anything, no host functions need to be provided.
  • instance.export("shipping_fee") retrieves the exported function. The name must match the #[no_mangle] in Rust.
  • apply(...) takes and returns long[] because Wasm number types (i32, i64…) are represented uniformly as long. You cast back to int yourself.

Try it out:

var calculator = new WasmShippingCalculator("/shipping.wasm");
System.out.println(calculator.fee(1_200, 10));
35000

The result includes 15,000 base fee, plus 2 × 5,000 for weight (1,200 / 500 = 2), plus 10 × 1,000 for distance.

5.4. Test behavior

package dev.nonla.shipping;

import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

class WasmShippingCalculatorTest {

    static WasmShippingCalculator calculator;

    @BeforeAll
    static void setUp() {
        calculator = new WasmShippingCalculator("/shipping.wasm");
    }

    @Test
    void givenLightParcelNearby_whenFee_thenOnlyBaseAndDistance() {
        assertThat(calculator.fee(300, 2)).isEqualTo(17_000);
    }

    @Test
    void givenHeavyParcelFarAway_whenFee_thenAllComponentsAdded() {
        assertThat(calculator.fee(2_000, 50)).isEqualTo(15_000 + 20_000 + 50_000);
    }

    @Test
    void givenMissingModule_whenCreate_thenFailFast() {
        assertThatThrownBy(() -> new WasmShippingCalculator("/not-found.wasm"))
            .isInstanceOf(IllegalStateException.class)
            .hasMessageContaining("not-found.wasm");
    }
}

The third test ensures the application fails at startup if the module is missing, rather than waiting until the first request fails.

Note: An Instance has its own memory and state. Don’t assume it’s thread-safe by default. For a pure compute module, the simplest approach is to create an instance per request, or use an instance pool. The parsed module itself can be reused to create multiple instances.

5.5. When a module needs WASI

If a plugin needs to read files or print to stdout, it will import WASI functions. You must provide those functions when creating the instance. Chicory has its own wasi module for this. The principle still holds: grant only the directories, environment variables, and stdout/stderr the plugin actually needs. Don’t “inherit” the entire host environment for convenience.

6. Real-world application patterns

  • Plugin system: Let customers or partners customize logic (pricing, rule checking, data transformation) without running their code directly in your JVM.
  • Policy engine: Package authorization rules as a Wasm module to share across multiple services written in different languages.
  • Serverless and edge functions: Each request runs in an isolated instance with fast startup.
  • Reusing native libraries: Compile a C/Rust library (parser, codec, algorithm) to Wasm and call it from Java, avoiding JNI and the risk of crashing the entire JVM.
  • Extensions for infrastructure: Some proxies, databases, and message brokers allow extension via Wasm.

7. When NOT to use it

Server-side Wasm is attractive, but isn’t always the right choice:

  • Logic already written well in Java and doesn’t need isolation: Adding a Wasm layer only increases complexity, data transfer overhead, and debugging difficulty.
  • Workload needs heavy multi-threading, GPU, or deep SIMD: Support for threads and specialized hardware features in Wasm is still limited and inconsistent across runtimes.
  • Application relies heavily on networking and the library ecosystem: Sockets, TLS, database drivers via WASI are incomplete compared to a real OS. Many libraries can’t compile to Wasm yet.
  • Frequent cross-boundary calls with large data: Each string or array transfer between host and guest must be copied into linear memory. If you call Wasm functions millions of times with large payloads, this overhead is significant. Batch work into large, infrequent calls.
  • Thinking sandbox means absolute safety: Wasm isolates memory, but doesn’t prevent infinite loops or CPU exhaustion by itself. You still need timeouts, memory limits, and execution interrupt mechanisms if your runtime supports them (fuel, epoch interruption…).
  • Your team isn’t ready yet: Tooling, debuggers, and observability for Wasm are still developing. Don’t introduce it to critical systems just because it’s “hot”.

Some common mistakes:

  • Building the wrong target. A module using std::fs or println! needs a WASI target. A pure compute module uses wasm32-unknown-unknown.
  • Forgetting #[no_mangle] or extern "C", so functions don’t appear in the export list.
  • Type confusion: Wasm only has primitive number types. To pass strings, you must manually manage allocation in linear memory, or use Component Model with WIT.
  • Granting too-broad permissions (--dir=/, inherit all env), losing the biggest benefit of WASI.

8. Conclusion

WASI transforms WebAssembly from a browser technology into a portable, lightweight, tightly-sandboxed execution format for servers. The capability model (no permissions unless granted) is the key difference from traditional processes and containers.

In this article, you built a WASI program and saw how filesystem permissions are controlled. You also embedded a Wasm module in a Java 21 application using a pure-Java runtime and wrote tests for it.

Consider Wasm when you need to run untrusted code, need multi-language plugins, or need extremely fast startup. Otherwise, if the problem is just typical business logic in a Java service, the simplest solution remains the best.

webassemblywasiwasmjavatech-trends