WebAssembly
Updated
7 min read

WebAssembly on the server: Compiling Rust to WASM and executing it from Go

Server-side WebAssembly: Unifying cross-language logic for high performance and data privacy. Learn how Arcjet leverages WASM for local-first processing.

WebAssembly on the server: Compiling Rust to WASM and executing it from Go

WebAssembly is often discussed in the web browser context. It’s usually compiled from another language like Go, Rust, or C++, and it gives you a secure sandbox that is often much more performant than executing JavaScript in the browser.

The key properties of WebAssembly provide a memory-safe, sandboxed execution environment in a compiled, efficient binary format that gives you near-native performance. This makes it great for security use cases, complex calculations, or running code that wasn’t originally designed for the browser.

The most famous use case is Figma’s C++ codebase that is run in the browser with WebAssembly. However, there are other interesting examples such as running a Linux VM, Postgres, SQLite embedded in a web application.

These examples are all about the browser, but WebAssembly works just as well server-side. Using a runtime library you can host a WebAssembly binary and call it from the host language. It’s the same principle as calling WebAssembly from JS in the browser, except you can do it from Rust or Go or Python.

But why not just write all your code in the host language? If you control the execution environment then sure, it’s easier to stick with your language of choice. However, if you need to support multiple environments across multiple tech stacks, server-side WebAssembly really shines.

In this post I’ll discuss how we use the same server-side WebAssembly in both our Go server and our JS SDK.

Why use WebAssembly?

Arcjet helps developers safeguard their applications with features like bot detection, rate limiting, signup form protection, and attack detection. Our SDKs provide a native interface for various programming languages, taking application requests and passing them to our WebAssembly module for analysis based on your configured rules.

In many cases, we make decisions based on both local analysis within the WebAssembly module and remote analysis using our low-latency decision API. For instance, when protecting signup forms, we verify email syntax locally and then send the address to our API to confirm domain registration and valid MX records. This allows us to make additional network calls e.g. DNS, which might be blocked or restricted locally.

In some cases we can return a decision entirely using the analysis performed locally in the WebAssembly. However, if we need to do server-side analysis then we re-run the analysis on our servers. This is part of our overall philosophy to not trust any external input, even if it comes from our SDK, but there are other reasons to execute the same checks locally and server side:

  • Consistency Across Languages: Subtle differences in library implementations or algorithms across programming languages (like JavaScript, Python, Go, Ruby, PHP) can lead to inconsistent results. Our WebAssembly module, written in Rust, guarantees uniform behavior regardless of the SDK's language. We previously used Rust's ability to compile with FFI support which could then be loaded into Go, but that introduced another opportunity for differences. It also required some specific build steps, which added complexity.
  • Eliminate Code Duplication: Rewriting complex analysis logic in every language we support is inefficient. WebAssembly allows us to write the core functionality once and reuse it everywhere.
  • Defense in Depth: Even when input comes to our API through our SDK, we maintain a "trust but verify" approach. By re-checking everything on our servers, we add an extra layer of security, ensuring no data manipulation occurs before reaching our API.
  • SDK Fallback: WebAssembly is widely supported, but it's not universal. Our API acts as a reliable fallback, providing the same functionality even if a particular environment lacks WebAssembly support.

The result is that we write our core functionality in Rust and then compile it to WebAssembly. The interface is defined using WIT which we can use to generate bindings for the host language.

Calling WebAssembly from Go with wazero

While Go can be compiled to WebAssembly, it lacks built-in support for loading and executing external WebAssembly code. Wazero fills this gap, providing idiomatic Go APIs such as context propagation and concurrency for seamlessly incorporating WebAssembly modules into our Go server. 

Wazero is zero dependency so it doesn’t require cgo. This makes cross compilation easy which is helpful because we develop on Apple Silicon (arm64) computers, but deploy to both arm64 and x86 environments. This keeps our toolchain simple.

With Wazero, we can focus on writing our core logic in pure Rust. Take our validate_email function as an example. This function parses an email address, checking for compliance with various format specifications. We then use a separate Rust crate to define the functions we want to expose through WebAssembly, using the WebAssembly component model with WIT files for interface definition.

pub fn validate_email<OutcomeType: PartialEq<bool>>(
    _ctx: &Context,
    candidate: &str,
    blocked_email_types: EmailTypes,
    options: ValidationOptions,
    validator: EmailValidator<OutcomeType>,
) -> EmailDecision {
    let email_address = match EmailAddress::parse_with_options(candidate, options.into()) {
        Ok(email) => email,
        // We ignore the error because we don't expose this to users.
        Err(_) => {
            return EmailDecision::Denied {
                blocked: EmailTypes(vec![EmailType::Invalid]),
            }
        }
    };

    validator.analyze(&email_address, blocked_email_types)
}

We have a separate bindings crate which acts as a bridge between Go and WebAssembly. It receives input from the Go host, converts types as needed, calls the underlying Rust functions, and then translates the results back into Go-compatible types.

The bindings look like this:

use arcjet::email_validator::{
    logger::{debug, error, log, warn},
    overrides::{has_gravatar, has_mx_records, is_disposable_email, is_free_email},
};
use arcjet_core::{
    context::{Context, Logger},
    email::{EmailTypes, EmailValidator, ValidationOptions},
};

wit_bindgen::generate!({
    world: "email-validator"
});

struct EmailValidatorWorld;

export!(EmailValidatorWorld);

impl Guest for EmailValidatorWorld {
    fn is_valid_email(candidate: String, options: EmailValidationConfig) -> Result<String, String> {
        let log = Logger::new(debug, log, warn, error);
        let ctx = &Context { log };

        let EmailValidationConfig {
            require_top_level_domain,
            allow_domain_literal,
            blocked_emails,
        } = options;

        let validation_options = ValidationOptions {
            require_top_level_domain,
            allow_domain_literal,
        };

        let blocked_email_types =
            EmailTypes::try_from(blocked_emails).map_err(|e| e.to_string())?;

        ctx.log.debug(format!(
            "is_valid_email input: {}, blocked email types: {:?}, options: {:?}",
            &candidate, &blocked_email_types, &validation_options
        ));

        let validator = EmailValidator::new(
            is_free_email,
            is_disposable_email,
            has_mx_records,
            has_gravatar,
        );

        let result = arcjet_core::email::validate_email(
            ctx,
            candidate.as_str(),
            blocked_email_types,
            validation_options,
            validator,
        );

        serde_json::to_string(&result).map_err(|e| e.to_string())
    }
}

We include some additional core utilities such as logging, but the associated WIT file to define the interface looks like this:

package arcjet:email-validator;

interface logger {
  debug: func(msg: string);
  log: func(msg: string);
  warn: func(msg: string);
  error: func(msg: string);
}

// interface email-validator-overrides {
interface overrides {
  is-free-email: func(domain: string) -> bool;
  is-disposable-email: func(domain: string) -> bool;
  has-mx-records: func(domain: string) -> bool;
  has-gravatar: func(email: string) -> bool;
}

world email-validator {
  import logger;
  import overrides;

  record email-validation-config {
    /**
     * If `true`, requires at least 2 segments in the `domain` part of an email address
     */
    require-top-level-domain: bool,
    /**
     * If `true`, allows email addresses in the form of `example@[127.0.0.1]`
     */
    allow-domain-literal: bool,
    /**
     * A list of email types that should be blocked. If any of these are detected then a block
     * result will be returned.
     */
    blocked-emails: list<string>,
  }
  
  export is-valid-email: func(candidate: string, options: email-validation-config) -> result<string, string>;
}

Essentially, Wazero allows us to treat our WebAssembly module like any other Go module, simplifying integration and execution within our Go server environment.

We then call it directly:

func (f *EmailValidator) ValidateEmail(
	ctx context.Context,
	candididate string,
	options *EmailValidationConfig,
) (*Decision, error) {
	inst, err := f.factory.Instantiate(ctx)
	if err != nil {
		return nil, err
	}

	result, err := inst.IsValidEmail(ctx, candididate, options)
	if err != nil {
		return nil, err
	}

	var decision Decision
	err = json.Unmarshal([]byte(result), &decision)
	if err != nil {
		return nil, err
	}

	return &decision, nil
}

Dealing with native types

A significant advantage of the WebAssembly component model is its ability to handle complex data types, eliminating the need for cumbersome memory manipulation previously required when working with strings or other non-numeric types.

Importantly, this now means you can pass strings between the host and WebAssembly, which is why we have this translation layer as its own crate. The separation allows us to use native Rust types (and our own custom types) as normal in the pure Rust crate, and let the bridge layer convert between them. For example, we can deal with a net.IP in Go, convert it to a string to pass it through into WebAssembly, then do the conversion to work with std::net::IpAddr in Rust.

An alternative approach is to use JSON as an interchange format. While this introduces a minor overhead, our profiling demonstrates that JSON serialization and deserialization are efficient in both Go and Rust, making it a practical solution for complex type interactions. This approach strikes a balance between type safety and performance, ensuring a smooth and reliable exchange of information between the two languages. However, everything defined in WIT should be translatable if you generate the correct bindings - something we’ve been working on building.

Generating WebAssembly component bindings for Go

One of the main hurdles we encountered using Wazero for executing WebAssembly in Go was its lack of support for the new component model, which is still under active development. While tools like jco exist for generating JavaScript bindings, no equivalent solution seamlessly integrates with Wazero for Go. We did look at wasmtime and Extism, but they both require certain dependencies which we wanted to avoid - cgo for the reasons discussed above and Extism’s own system-level libraries (more of an issue in the JS SDK).

To address this, we created our own tooling using genco that generates Go bindings from the WIT file. This allows us to pull Go values into WebAssembly memory using the correct conventions, such as allocating memory with the realloc function. We’re in the process of cleaning up the tooling ready to be open sourced soon.

This custom tooling streamlines our development workflow, allowing us to embed the generated .wasm binary directly into our Go program at build time using Go's embed functionality. We then instantiate the module once at server startup, ensuring optimal performance and minimizing overhead.

The end result

By centralizing our core functionality in Rust and compiling it to WebAssembly, we've achieved a significant boost in efficiency and code reusability. This enables us to deliver consistent, high-performance security features across our Go backend, JavaScript SDK, and future SDKs in other languages.

Our WebAssembly module now handles a wide array of critical tasks, including bot detection, email validation, client fingerprinting, and aspects of rate limiting. By decentralizing analysis to run locally wherever our SDK is deployed, we dramatically reduce latency and give developers more control over their data. This "local-first" philosophy is a core principle as we expand our capabilities.

Related articles

Subscribe by email

Get the full posts by email every week.