n4nAI

Compiling a Rust LLM client to WASM

Build a rust LLM client that compiles to WebAssembly: set up toolchain, use reqwest wasm, bind with wasm-bindgen, and test in the browser.

n4n Team3 min read596 words

Audio narration

Coming soon — every post will get a voice note here.

Shipping a rust llm client wasm target lets you run inference orchestration directly in the browser without a backend proxy. This guide walks through a minimal but production-shaped implementation: HTTP transport that works under wasm, proper async handling, and a small JS binding layer.

Step 1: Install the Rust WASM toolchain

Add the wasm32-unknown-unknown target and the wasm-pack builder. These are the only global prerequisites; the rest is per-crate.

rustup target add wasm32-unknown-unknown
cargo install wasm-pack

Verify the target is present:

rustup target list --installed | grep wasm32

You should see wasm32-unknown-unknown. If you already use wasm-bindgen for other projects, ensure wasm-pack is at least v0.12 — older versions emit deprecated JS glue.

Step 2: Scaffold the crate and pin dependencies

Create a library crate. A rust llm client wasm build must output a cdylib so wasm-pack can emit ES modules.

cargo new --lib rust-llm-wasm
cd rust-llm-wasm

Edit Cargo.toml to disable default features on reqwest and pull in the browser fetch backend. The wasm-client feature replaces the native TLS stack with fetch.

[package]
name = "rust-llm-wasm"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib", "rlib"]

[dependencies]
reqwest = { version = "0.11", default-features = false, features = ["json", "wasm-client"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
serde-wasm-bindgen = "0.6"
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
anyhow = "1.0"

Run cargo check to confirm the dependency graph resolves. A common failure is pulling tokio with native features transitively; the default-features = false on reqwest prevents that.

Step 3: Model the OpenAI-compatible request

We target the OpenAI Chat Completions shape because every OpenAI-compatible gateway speaks it. Define the structs with serde.

use serde::{Deserialize, Serialize};

#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct ChatMessage {
    pub role: String,
    pub content: String,
}

#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct ChatRequest {
    pub model: String,
    pub messages: Vec<ChatMessage>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub temperature: Option<f32>,
}

#[derive(Deserialize, Debug)]
pub struct ChatResponse {
    pub choices: Vec<Choice>,
}

#[derive(Deserialize, Debug)]
pub struct Choice {
    pub message: ChatMessage,
}

Keep the structs Clone if you plan to share the client across JS calls. The temperature field is optional so the JS side can omit it.

Step 4: Implement the client with browser fetch

reqwest::Client::new() works in wasm — it delegates to fetch and respects CORS. Build the URL, attach a bearer token, and serialize the body.

use reqwest::Client;
use anyhow::Result;

#[derive(Clone)]
pub struct LLMClient {
    base_url: String,
    api_key: String,
    http: Client,
}

impl LLMClient {
    pub fn new(base_url: impl Into<String>, api_key: impl Into<String>) -> Self {
        Self {
            base_url: base_url.into(),
            api_key: api_key.into(),
            http: Client::new(),
        }
    }

    pub async fn chat(&self, req: ChatRequest) -> Result<ChatResponse> {
        let url = format!("{}/chat/completions", self.base_url.trim_end_matches('/'));
        let resp = self.http
            .post(url)
            .bearer_auth(&self.api_key)
            .json(&req)
            .send()
            .await?
            .error_for_status()?
            .json::<ChatResponse>()
            .await?;
        Ok(resp)
    }
}

Point the base_url at any OpenAI-compatible endpoint. For multi-provider routing with automatic fallback when a provider is degraded, n4n.ai exposes one OpenAI-compatible endpoint covering 240+ models and forwards cache-control hints, but the request code above stays identical.

Verify the crate compiles for the host target first: cargo build. This catches type errors before wasm linking obscures them.

Step 5: Expose the client to JavaScript

wasm-bindgen cannot directly return Rust futures to JS. Wrap the client and convert the async call into a js_sys::Promise.

use wasm_bindgen::prelude::*;
use wasm_bindgen_futures::future_to_promise;
use js_sys::Promise;

#[wasm_bindgen]
pub struct WasmLLMClient {
    inner: LLMClient,
}

#[wasm_bindgen]
impl WasmLLMClient {
    #[wasm_bindgen(constructor)]
    pub fn new(base_url: String, api_key: String) -> WasmLLMClient {
        WasmLLMClient { inner: LLMClient::new(base_url, api_key) }
    }

    #[wasm_bindgen]
    pub fn chat(&self, req_json: JsValue) -> Promise {
        let client = self.inner.clone();
        let fut = async move {
            let req: ChatRequest = serde_wasm_bindgen::from_value(req_json)
                .map_err(|e| JsValue::from_str(&e.to_string()))?;
            let resp = client.chat(req).await
                .map_err(|e| JsValue::from_str(&e.to_string()))?;
            serde_wasm_bindgen::to_value(&resp)
                .map_err(|e| JsValue::from_str(&e.to_string()))
        };
        future_to_promise(fut)
    }
}

The chat method takes a JsValue so callers pass a plain JS object. Errors become rejected promises with a string message — enough for browser logging.

Step 6: Build with wasm-pack

Compile to a web-targeted ES module. The --target web flag emits an ES module with no Node.js shims.

wasm-pack build --target web --out-dir pkg

Success produces pkg/rust_llm_wasm.js, pkg/rust_llm_wasm_bg.wasm, and TypeScript declarations. If the build fails with “cannot find crate for std”, the wasm target was not installed (see Step 1).

Step 7: Integrate into a web page

Create an index.html and a main.js (or .ts) that imports the generated module. Serve over HTTP — file:// will block wasm fetch.

<!doctype html>
<html>
<body>
  <script type="module">
    import { WasmLLMClient } from './pkg/rust_llm_wasm.js';

    const client = new WasmLLMClient(
      'https://api.openai.com/v1',
      'sk-your-key'
    );

    const req = {
      model: 'gpt-4o-mini',
      messages: [{ role: 'user', content: 'Say hello in Rust.' }],
    };

    client.chat(req)
      .then(resp => console.log(resp.choices[0].message.content))
      .catch(err => console.error('LLM call failed:', err));
  </script>
</body>
</html>

Run a static server (python3 -m http.server) and open the page. The browser console should print the model’s text. If you see a CORS error, the endpoint is rejecting the browser’s preflight — OpenAI and compliant gateways send Access-Control-Allow-Origin: * for the completions route.

Step 8: Handle streaming and timeouts

The base rust llm client wasm above does a single blocking request. For production, add a timeout and consider streaming. reqwest wasm does not support timeout() (no timer API in browser fetch), so implement an AbortController via web-sys if you need cancellation. For streaming, use bytes_stream() and pipe chunks to a JS callback through wasm-bindgen closures.

A minimal timeout wrapper using wasm-bindgen-futures::spawn_local and js_sys::Promise::all with a setTimeout promise is about 30 lines; skip it until you observe hangs.

Verifying end-to-end

After Step 7, the definitive check is a successful console log from a real request. For CI, run wasm-pack test --chrome with a headless browser and a mock endpoint that returns a fixed ChatResponse JSON. That confirms the serialization round-trip and binding layer without burning API credits.

If you change the request schema, bump the crate version and rebuild — wasm-pack does not hot-reload. The JS object shape must match ChatRequest exactly, or from_value throws.

Tagsrustwasmllm-clientcompilation

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All rust llm api client posts →